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::diagnostic::{Diagnostic, DiagnosticCollector, DiagnosticLevel};
9use crate::error::MarsError;
10use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
11use crate::types::MarsContext;
12
13/// Manage model aliases and the models cache.
14#[derive(Debug, Parser)]
15pub struct ModelsArgs {
16    #[command(subcommand)]
17    pub command: ModelsCommand,
18}
19
20#[derive(Debug, Subcommand)]
21pub enum ModelsCommand {
22    /// Fetch models from API and update the local cache.
23    Refresh,
24    /// List all model aliases (consumer + deps) with resolved IDs.
25    List(ListArgs),
26    /// Show resolution chain for a specific alias.
27    Resolve(ResolveAliasArgs),
28    /// Quick-add a pinned alias to mars.toml [models].
29    Alias(AddAliasArgs),
30}
31
32#[derive(Debug, Parser)]
33pub struct ListArgs {
34    /// Show all alias-filter candidates (not just winners).
35    #[arg(
36        long,
37        conflicts_with = "catalog",
38        conflicts_with = "include",
39        conflicts_with = "exclude"
40    )]
41    all: bool,
42    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
43    #[arg(long)]
44    no_refresh_models: bool,
45    /// Only show aliases matching these patterns (overrides config).
46    #[arg(
47        long,
48        value_delimiter = ',',
49        conflicts_with = "exclude",
50        conflicts_with = "catalog",
51        conflicts_with = "all"
52    )]
53    include: Option<Vec<String>>,
54    /// Hide aliases matching these patterns (overrides config).
55    #[arg(
56        long,
57        value_delimiter = ',',
58        conflicts_with = "include",
59        conflicts_with = "catalog",
60        conflicts_with = "all"
61    )]
62    exclude: Option<Vec<String>>,
63    /// Show all models from the cache (not just alias-covered models).
64    #[arg(
65        long,
66        conflicts_with = "include",
67        conflicts_with = "exclude",
68        conflicts_with = "all"
69    )]
70    catalog: bool,
71}
72
73#[derive(Debug, Parser)]
74pub struct ResolveAliasArgs {
75    /// Alias name to resolve.
76    pub name: String,
77    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
78    #[arg(long)]
79    no_refresh_models: bool,
80}
81
82#[derive(Debug, Parser)]
83pub struct AddAliasArgs {
84    /// Alias name.
85    pub name: String,
86    /// Model ID to pin.
87    pub model_id: String,
88    /// Harness for this alias (default: claude).
89    #[arg(long, default_value = "claude")]
90    pub harness: String,
91    /// Optional description.
92    #[arg(long)]
93    pub description: Option<String>,
94}
95
96pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
97    match &args.command {
98        ModelsCommand::Refresh => run_refresh(ctx, json),
99        ModelsCommand::List(args) => run_list(args, ctx, json),
100        ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
101        ModelsCommand::Alias(a) => run_alias(a, ctx, json),
102    }
103}
104
105fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
106    ctx.project_root.join(".mars")
107}
108
109fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
110    let mars = mars_dir(ctx);
111    let ttl = models::load_models_cache_ttl(ctx);
112    eprint!("Fetching models catalog... ");
113
114    let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
115    let count = cache.models.len();
116    let cache_warning = cache_warning(&outcome);
117
118    if let Some(warning) = cache_warning.as_deref() {
119        eprintln!("warning: {warning}");
120    } else if !json {
121        eprintln!("done.");
122    }
123
124    if json {
125        let out = serde_json::json!({
126            "status": "ok",
127            "models_count": count,
128            "fetched_at": cache.fetched_at,
129        });
130        let mut out = out;
131        if let Some(warning) = cache_warning.as_deref() {
132            out["cache_warning"] = serde_json::json!(warning);
133        }
134        println!("{}", serde_json::to_string_pretty(&out).unwrap());
135    } else {
136        if cache_warning.is_some() {
137            println!(
138                "Using stale models cache with {} models in .mars/models-cache.json",
139                count
140            );
141        } else {
142            println!("Cached {} models in .mars/models-cache.json", count);
143        }
144    }
145
146    Ok(0)
147}
148
149fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
150    let mars = mars_dir(ctx);
151    let ttl = models::load_models_cache_ttl(ctx);
152    let mode = models::resolve_refresh_mode(args.no_refresh_models);
153    let Some((cache, outcome)) = ensure_fresh_or_json_error(&mars, ttl, mode, json)? else {
154        return Ok(1);
155    };
156
157    if args.catalog {
158        return run_list_catalog(&cache, &outcome, json);
159    }
160
161    // Load config to get consumer models + trigger merge
162    let merged = load_merged_aliases(ctx)?;
163    if args.all {
164        return run_list_all(&merged, &cache, &outcome, json);
165    }
166
167    let cache_warning = cache_warning(&outcome);
168    let mut diag = DiagnosticCollector::new();
169
170    let resolved = models::resolve_all(&merged, &cache, &mut diag);
171
172    // Build effective visibility: CLI overrides config entirely.
173    let config_visibility = crate::config::load(&ctx.project_root)
174        .map(|c| c.settings.model_visibility)
175        .unwrap_or_default();
176
177    let visibility = if args.include.is_some() || args.exclude.is_some() {
178        crate::config::ModelVisibility {
179            include: args.include.clone(),
180            exclude: args.exclude.clone(),
181        }
182    } else {
183        config_visibility
184    };
185
186    let resolved = models::filter_by_visibility(resolved, &visibility);
187
188    if json {
189        let entries: Vec<serde_json::Value> = resolved
190            .values()
191            .map(|r| {
192                let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
193                let mut obj = serde_json::json!({
194                    "name": r.name,
195                    "harness": r.harness,
196                    "harness_source": r.harness_source,
197                    "harness_candidates": r.harness_candidates,
198                    "provider": r.provider,
199                    "mode": mode,
200                    "model_id": r.model_id,
201                    "resolved_model": r.model_id,
202                    "description": r.description,
203                });
204                if let Some(error) = unavailable_harness_error(r) {
205                    obj["error"] = serde_json::json!(error);
206                }
207                if let Some(default_effort) = &r.default_effort {
208                    obj["default_effort"] = serde_json::json!(default_effort);
209                }
210                if let Some(autocompact) = r.autocompact {
211                    obj["autocompact"] = serde_json::json!(autocompact);
212                }
213                obj
214            })
215            .collect();
216        let mut out = serde_json::json!({
217            "aliases": entries,
218            "cache_available": cache.fetched_at.is_some(),
219        });
220        if let Some(warning) = cache_warning.as_deref() {
221            out["cache_warning"] = serde_json::json!(warning);
222        }
223        if let Some(diagnostics) = drain_diagnostics_json(&mut diag) {
224            out["diagnostics"] = diagnostics;
225        }
226        println!("{}", serde_json::to_string_pretty(&out).unwrap());
227    } else {
228        if let Some(warning) = cache_warning.as_deref() {
229            eprintln!("warning: {warning}");
230        }
231        // Table output
232        println!(
233            "{:<12} {:<10} {:<14} {:<30} {}",
234            "ALIAS", "HARNESS", "MODE", "RESOLVED", "DESCRIPTION"
235        );
236        for r in resolved.values() {
237            if r.harness_source == HarnessSource::Unavailable {
238                continue;
239            }
240            let harness = r.harness.as_deref().unwrap_or("—");
241            let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
242            let desc = r.description.clone().unwrap_or_default();
243            println!(
244                "{:<12} {:<10} {:<14} {:<30} {}",
245                r.name, harness, mode, r.model_id, desc
246            );
247        }
248        emit_text_diagnostics(&mut diag);
249    }
250
251    Ok(0)
252}
253
254#[derive(Debug, Clone)]
255struct ListModelEntry {
256    id: String,
257    provider: String,
258    release_date: Option<String>,
259    harness: Option<String>,
260    harness_source: HarnessSource,
261    harness_candidates: Vec<String>,
262    description: Option<String>,
263    matched_aliases: Vec<String>,
264}
265
266fn run_list_all(
267    merged: &IndexMap<String, ModelAlias>,
268    cache: &models::ModelsCache,
269    outcome: &models::RefreshOutcome,
270    json: bool,
271) -> Result<i32, MarsError> {
272    let cache_warning = cache_warning(outcome);
273    let models = collect_all_model_entries(merged, cache);
274
275    if json {
276        let entries: Vec<serde_json::Value> = models
277            .into_iter()
278            .map(|model| {
279                serde_json::json!({
280                    "id": model.id,
281                    "provider": model.provider,
282                    "release_date": model.release_date,
283                    "harness": model.harness,
284                    "harness_source": model.harness_source,
285                    "harness_candidates": model.harness_candidates,
286                    "description": model.description,
287                    "matched_aliases": model.matched_aliases,
288                })
289            })
290            .collect();
291        let mut out = serde_json::json!({
292            "models": entries,
293            "cache_available": cache.fetched_at.is_some(),
294        });
295        if let Some(warning) = cache_warning.as_deref() {
296            out["cache_warning"] = serde_json::json!(warning);
297        }
298        println!("{}", serde_json::to_string_pretty(&out).unwrap());
299    } else {
300        if let Some(warning) = cache_warning.as_deref() {
301            eprintln!("warning: {warning}");
302        }
303        println!(
304            "{:<10} {:<34} {:<12} {:<10} {}",
305            "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "ALIASES"
306        );
307        for model in models {
308            let release = model.release_date.as_deref().unwrap_or("—");
309            let harness = model.harness.as_deref().unwrap_or("—");
310            println!(
311                "{:<10} {:<34} {:<12} {:<10} {}",
312                model.provider,
313                model.id,
314                release,
315                harness,
316                model.matched_aliases.join(",")
317            );
318        }
319    }
320
321    Ok(0)
322}
323
324fn run_list_catalog(
325    cache: &models::ModelsCache,
326    outcome: &models::RefreshOutcome,
327    json: bool,
328) -> Result<i32, MarsError> {
329    let cache_warning = cache_warning(outcome);
330    let models = collect_catalog_model_entries(cache);
331
332    if json {
333        let entries: Vec<serde_json::Value> = models
334            .into_iter()
335            .map(|model| {
336                serde_json::json!({
337                    "id": model.id,
338                    "provider": model.provider,
339                    "release_date": model.release_date,
340                    "harness": model.harness,
341                    "harness_source": model.harness_source,
342                    "harness_candidates": model.harness_candidates,
343                    "description": model.description,
344                })
345            })
346            .collect();
347        let mut out = serde_json::json!({
348            "models": entries,
349            "cache_available": cache.fetched_at.is_some(),
350        });
351        if let Some(warning) = cache_warning.as_deref() {
352            out["cache_warning"] = serde_json::json!(warning);
353        }
354        println!("{}", serde_json::to_string_pretty(&out).unwrap());
355    } else {
356        if let Some(warning) = cache_warning.as_deref() {
357            eprintln!("warning: {warning}");
358        }
359        println!(
360            "{:<10} {:<34} {:<12} {:<10}",
361            "PROVIDER", "MODEL ID", "RELEASE", "HARNESS"
362        );
363        for model in models {
364            let release = model.release_date.as_deref().unwrap_or("—");
365            let harness = model.harness.as_deref().unwrap_or("—");
366            println!(
367                "{:<10} {:<34} {:<12} {:<10}",
368                model.provider, model.id, release, harness
369            );
370        }
371    }
372
373    Ok(0)
374}
375
376fn collect_all_model_entries(
377    merged: &IndexMap<String, ModelAlias>,
378    cache: &models::ModelsCache,
379) -> Vec<ListModelEntry> {
380    let installed = models::harness::detect_installed_harnesses();
381    let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
382
383    for (alias_name, alias) in merged {
384        match &alias.spec {
385            ModelSpec::AutoResolve {
386                provider,
387                match_patterns,
388                exclude_patterns,
389            } => {
390                for matched in
391                    models::auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
392                {
393                    append_alias_match(&mut by_model_id, matched, &installed, alias_name);
394                }
395            }
396            ModelSpec::Pinned {
397                model, provider, ..
398            } => {
399                if let Some(matched) = cache
400                    .models
401                    .iter()
402                    .find(|cache_model| cache_model.id == *model)
403                {
404                    append_alias_match(&mut by_model_id, matched, &installed, alias_name);
405                } else {
406                    append_pinned_alias_match(
407                        &mut by_model_id,
408                        model,
409                        provider.as_deref(),
410                        alias.description.as_deref(),
411                        &installed,
412                        alias_name,
413                    );
414                }
415            }
416            ModelSpec::PinnedWithMatch {
417                model,
418                provider,
419                match_patterns,
420                exclude_patterns,
421            } => {
422                if let Some(matched) = cache
423                    .models
424                    .iter()
425                    .find(|cache_model| cache_model.id == *model)
426                {
427                    append_alias_match(&mut by_model_id, matched, &installed, alias_name);
428                } else {
429                    append_pinned_alias_match(
430                        &mut by_model_id,
431                        model,
432                        provider.as_deref(),
433                        alias.description.as_deref(),
434                        &installed,
435                        alias_name,
436                    );
437                }
438
439                let provider_for_discovery = provider
440                    .as_deref()
441                    .or_else(|| models::infer_provider_from_model_id(model));
442                if let Some(provider_for_discovery) = provider_for_discovery {
443                    for matched in models::auto_resolve_all(
444                        provider_for_discovery,
445                        match_patterns,
446                        exclude_patterns,
447                        cache,
448                    ) {
449                        append_alias_match(&mut by_model_id, matched, &installed, alias_name);
450                    }
451                }
452            }
453        }
454    }
455
456    let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
457    sort_list_model_entries(&mut out);
458    out
459}
460
461fn collect_catalog_model_entries(cache: &models::ModelsCache) -> Vec<ListModelEntry> {
462    let installed = models::harness::detect_installed_harnesses();
463    let mut out: Vec<ListModelEntry> = cache
464        .models
465        .iter()
466        .map(|model| model_entry_for_cached(model, &installed))
467        .collect();
468    sort_list_model_entries(&mut out);
469    out
470}
471
472fn append_alias_match(
473    by_model_id: &mut IndexMap<String, ListModelEntry>,
474    model: &models::CachedModel,
475    installed: &HashSet<String>,
476    alias_name: &str,
477) {
478    let entry = by_model_id
479        .entry(model.id.clone())
480        .or_insert_with(|| model_entry_for_cached(model, installed));
481
482    append_alias_name(entry, alias_name);
483}
484
485fn append_pinned_alias_match(
486    by_model_id: &mut IndexMap<String, ListModelEntry>,
487    model_id: &str,
488    provider: Option<&str>,
489    description: Option<&str>,
490    installed: &HashSet<String>,
491    alias_name: &str,
492) {
493    let entry = by_model_id
494        .entry(model_id.to_string())
495        .or_insert_with(|| model_entry_for_pinned(model_id, provider, description, installed));
496
497    append_alias_name(entry, alias_name);
498}
499
500fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
501    if !entry
502        .matched_aliases
503        .iter()
504        .any(|existing| existing == alias_name)
505    {
506        entry.matched_aliases.push(alias_name.to_string());
507    }
508}
509
510fn model_entry_for_cached(
511    model: &models::CachedModel,
512    installed: &HashSet<String>,
513) -> ListModelEntry {
514    let harness = models::harness::resolve_harness_for_provider(&model.provider, installed);
515    let harness_source = if harness.is_some() {
516        HarnessSource::AutoDetected
517    } else {
518        HarnessSource::Unavailable
519    };
520
521    ListModelEntry {
522        id: model.id.clone(),
523        provider: model.provider.clone(),
524        release_date: model.release_date.clone(),
525        harness,
526        harness_source,
527        harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
528        description: model.description.clone(),
529        matched_aliases: Vec::new(),
530    }
531}
532
533fn model_entry_for_pinned(
534    model_id: &str,
535    provider: Option<&str>,
536    description: Option<&str>,
537    installed: &HashSet<String>,
538) -> ListModelEntry {
539    let provider = provider
540        .map(str::to_string)
541        .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
542        .unwrap_or_else(|| "unknown".to_string());
543    let harness = models::harness::resolve_harness_for_provider(&provider, installed);
544    let harness_source = if harness.is_some() {
545        HarnessSource::AutoDetected
546    } else {
547        HarnessSource::Unavailable
548    };
549
550    ListModelEntry {
551        id: model_id.to_string(),
552        provider: provider.clone(),
553        release_date: None,
554        harness,
555        harness_source,
556        harness_candidates: models::harness::harness_candidates_for_provider(&provider),
557        description: description.map(str::to_string),
558        matched_aliases: Vec::new(),
559    }
560}
561
562fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
563    entries.sort_by(|a, b| {
564        a.provider
565            .to_ascii_lowercase()
566            .cmp(&b.provider.to_ascii_lowercase())
567            .then_with(|| {
568                b.release_date
569                    .as_deref()
570                    .unwrap_or("")
571                    .cmp(a.release_date.as_deref().unwrap_or(""))
572            })
573            .then_with(|| a.id.cmp(&b.id))
574    });
575}
576
577fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
578    let merged = load_merged_aliases(ctx)?;
579    let mars = mars_dir(ctx);
580    let ttl = models::load_models_cache_ttl(ctx);
581    let mode = models::resolve_refresh_mode(args.no_refresh_models);
582
583    // Cache is enrichment, not a gate. If unavailable, skip to passthrough.
584    let cache_result = ensure_fresh_or_json_error(&mars, ttl, mode, json)?;
585
586    if let Some((cache, outcome)) = &cache_result {
587        // Step 1: exact alias lookup
588        if let Some(alias) = merged.get(&args.name) {
589            return run_resolve_exact_alias(&args.name, alias, &merged, ctx, cache, outcome, json);
590        }
591
592        // Step 2: alias-prefix resolution
593        if let Some(resolved) = models::resolve_with_alias_prefix(&args.name, &merged, cache) {
594            return run_output_resolved(&args.name, &resolved, "alias_prefix", outcome, json);
595        }
596    }
597
598    // Step 3: passthrough — no cache needed
599    let outcome = cache_result
600        .as_ref()
601        .map(|(_, o)| o.clone())
602        .unwrap_or(models::RefreshOutcome::Offline);
603    run_output_passthrough(&args.name, &outcome, json)
604}
605
606fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
607    let mut config = crate::config::load(&ctx.project_root)?;
608    config.models.insert(
609        args.name.clone(),
610        ModelAlias {
611            harness: Some(args.harness.clone()),
612            description: args.description.clone(),
613            default_effort: None,
614            autocompact: None,
615            spec: ModelSpec::Pinned {
616                model: args.model_id.clone(),
617                provider: None,
618            },
619        },
620    );
621    crate::config::save(&ctx.project_root, &config)?;
622
623    if json {
624        println!(
625            "{}",
626            serde_json::to_string_pretty(&serde_json::json!({
627                "status": "ok",
628                "alias": args.name,
629                "model": args.model_id,
630                "harness": args.harness,
631            }))
632            .unwrap()
633        );
634    } else {
635        println!(
636            "Added alias `{}` → {} (harness: {})",
637            args.name, args.model_id, args.harness
638        );
639    }
640
641    Ok(0)
642}
643
644fn ensure_fresh_or_json_error(
645    mars: &std::path::Path,
646    ttl: u32,
647    mode: models::RefreshMode,
648    json: bool,
649) -> Result<Option<(models::ModelsCache, models::RefreshOutcome)>, MarsError> {
650    match models::ensure_fresh(mars, ttl, mode) {
651        Ok(ok) => Ok(Some(ok)),
652        Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
653            println!(
654                "{}",
655                serde_json::to_string_pretty(&serde_json::json!({
656                    "error": format!("{err}"),
657                }))
658                .unwrap()
659            );
660            Ok(None)
661        }
662        Err(err) => Err(err),
663    }
664}
665
666fn run_resolve_exact_alias(
667    name: &str,
668    alias: &ModelAlias,
669    merged: &IndexMap<String, ModelAlias>,
670    ctx: &MarsContext,
671    cache: &models::ModelsCache,
672    outcome: &models::RefreshOutcome,
673    json: bool,
674) -> Result<i32, MarsError> {
675    let cache_warning = cache_warning(outcome);
676    if let Some(warning) = cache_warning.as_deref()
677        && !json
678    {
679        eprintln!("warning: {warning}");
680    }
681
682    let source = determine_source(name, ctx)?;
683    let mut diag = DiagnosticCollector::new();
684    let resolved_entry = models::resolve_one(name, merged, cache, &mut diag);
685    let diagnostics = diag.drain();
686
687    if json {
688        if let Some(r) = resolved_entry.as_ref() {
689            let mut out = serde_json::json!({
690                "name": r.name,
691                "source": source,
692                "provider": r.provider,
693                "harness": r.harness,
694                "harness_source": r.harness_source,
695                "harness_candidates": r.harness_candidates,
696                "model_id": r.model_id,
697                "resolved_model": r.model_id,
698                "spec": format_spec(&alias.spec),
699                "description": r.description,
700            });
701            if let Some(error) = unavailable_harness_error(r) {
702                out["error"] = serde_json::json!(error);
703            }
704            if let Some(default_effort) = &r.default_effort {
705                out["default_effort"] = serde_json::json!(default_effort);
706            }
707            if let Some(autocompact) = r.autocompact {
708                out["autocompact"] = serde_json::json!(autocompact);
709            }
710            if let Some(warning) = cache_warning.as_deref() {
711                out["cache_warning"] = serde_json::json!(warning);
712            }
713            if !diagnostics.is_empty() {
714                out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
715            }
716            println!("{}", serde_json::to_string_pretty(&out).unwrap());
717        } else {
718            let mut out = serde_json::json!({
719                "error": format!("alias `{}` did not resolve to a model ID", name),
720            });
721            if let Some(warning) = cache_warning.as_deref() {
722                out["cache_warning"] = serde_json::json!(warning);
723            }
724            if !diagnostics.is_empty() {
725                out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
726            }
727            println!("{}", serde_json::to_string_pretty(&out).unwrap());
728            return Ok(1);
729        }
730    } else {
731        let Some(r) = resolved_entry.as_ref() else {
732            eprintln!("error: alias `{}` did not resolve to a model ID", name);
733            return Ok(1);
734        };
735        let harness = r.harness.as_deref().unwrap_or("—");
736        println!("Alias:    {}", name);
737        println!("Source:   {}", source);
738        println!(
739            "Harness:  {} ({})",
740            harness,
741            harness_source_label(&r.harness_source)
742        );
743        println!("Provider: {}", r.provider);
744        match &alias.spec {
745            ModelSpec::Pinned { model, provider: _ } => {
746                println!("Mode:     pinned");
747                println!("Model:    {}", model);
748            }
749            ModelSpec::PinnedWithMatch {
750                model,
751                provider: _,
752                match_patterns,
753                exclude_patterns,
754            } => {
755                println!("Mode:     pinned");
756                println!("Model:    {}", model);
757                println!("Match:    {}", match_patterns.join(", "));
758                if !exclude_patterns.is_empty() {
759                    println!("Exclude:  {}", exclude_patterns.join(", "));
760                }
761                println!("Resolved: {}", r.model_id);
762            }
763            ModelSpec::AutoResolve {
764                provider: _,
765                match_patterns,
766                exclude_patterns,
767            } => {
768                println!("Mode:     auto-resolve");
769                println!("Match:    {}", match_patterns.join(", "));
770                if !exclude_patterns.is_empty() {
771                    println!("Exclude:  {}", exclude_patterns.join(", "));
772                }
773                println!("Resolved: {}", r.model_id);
774            }
775        }
776        if let Some(error) = unavailable_harness_error(r) {
777            println!("Error:    {}", error);
778        }
779        if let Some(desc) = &r.description {
780            println!("Desc:     {}", desc);
781        }
782        emit_drained_text_diagnostics(&diagnostics);
783    }
784
785    Ok(0)
786}
787
788fn run_output_resolved(
789    name: &str,
790    resolved: &models::ResolvedAlias,
791    source: &str,
792    outcome: &models::RefreshOutcome,
793    json: bool,
794) -> Result<i32, MarsError> {
795    let cache_warning = cache_warning(outcome);
796    if let Some(warning) = cache_warning.as_deref()
797        && !json
798    {
799        eprintln!("warning: {warning}");
800    }
801
802    if json {
803        let mut out = serde_json::json!({
804            "name": name,
805            "source": source,
806            "provider": resolved.provider,
807            "harness": resolved.harness,
808            "harness_source": resolved.harness_source,
809            "harness_candidates": resolved.harness_candidates,
810            "model_id": resolved.model_id,
811            "resolved_model": resolved.model_id,
812            "description": resolved.description,
813        });
814        if let Some(error) = unavailable_harness_error(resolved) {
815            out["error"] = serde_json::json!(error);
816        }
817        if let Some(default_effort) = &resolved.default_effort {
818            out["default_effort"] = serde_json::json!(default_effort);
819        }
820        if let Some(autocompact) = resolved.autocompact {
821            out["autocompact"] = serde_json::json!(autocompact);
822        }
823        if let Some(warning) = cache_warning.as_deref() {
824            out["cache_warning"] = serde_json::json!(warning);
825        }
826        println!("{}", serde_json::to_string_pretty(&out).unwrap());
827    } else {
828        let harness = resolved.harness.as_deref().unwrap_or("—");
829        println!("Alias:    {}", name);
830        println!("Source:   {}", source);
831        println!(
832            "Harness:  {} ({})",
833            harness,
834            harness_source_label(&resolved.harness_source)
835        );
836        println!("Provider: {}", resolved.provider);
837        println!("Resolved: {}", resolved.model_id);
838        if let Some(error) = unavailable_harness_error(resolved) {
839            println!("Error:    {}", error);
840        }
841        if let Some(desc) = &resolved.description {
842            println!("Desc:     {}", desc);
843        }
844    }
845
846    Ok(0)
847}
848
849fn run_output_passthrough(
850    name: &str,
851    outcome: &models::RefreshOutcome,
852    json: bool,
853) -> Result<i32, MarsError> {
854    if name.trim().is_empty() {
855        if json {
856            println!(
857                "{}",
858                serde_json::to_string_pretty(&serde_json::json!({
859                    "error": "model name cannot be empty"
860                }))
861                .unwrap()
862            );
863        } else {
864            eprintln!("error: model name cannot be empty");
865        }
866        return Ok(1);
867    }
868
869    let cache_warning = cache_warning(outcome);
870    if let Some(warning) = cache_warning.as_deref()
871        && !json
872    {
873        eprintln!("warning: {warning}");
874    }
875
876    let installed = models::harness::detect_installed_harnesses();
877    let guessed_provider = models::infer_provider_from_model_id(name).map(str::to_string);
878    let harness = guessed_provider
879        .as_deref()
880        .and_then(|p| models::harness::resolve_harness_for_provider(p, &installed));
881    let harness_source = if harness.is_some() {
882        "pattern_guess"
883    } else {
884        "unavailable"
885    };
886    let harness_candidates = guessed_provider
887        .as_deref()
888        .map(models::harness::harness_candidates_for_provider)
889        .unwrap_or_default();
890
891    let warning = format!(
892        "model '{}' not found in catalog, passing through to harness",
893        name
894    );
895
896    if json {
897        let mut out = serde_json::json!({
898            "name": name,
899            "source": "passthrough",
900            "model_id": name,
901            "resolved_model": name,
902            "provider": guessed_provider,
903            "harness": harness,
904            "harness_source": harness_source,
905            "harness_candidates": harness_candidates,
906            "description": serde_json::Value::Null,
907            "warning": warning,
908        });
909        if let Some(warning) = cache_warning.as_deref() {
910            out["cache_warning"] = serde_json::json!(warning);
911        }
912        println!("{}", serde_json::to_string_pretty(&out).unwrap());
913    } else {
914        eprintln!("warning: {}", warning);
915        let h = harness.as_deref().unwrap_or("—");
916        println!("Model:      {}", name);
917        println!("Source:     passthrough");
918        println!("Harness:    {} ({})", h, harness_source);
919        if let Some(provider) = guessed_provider {
920            println!("Provider:   {}", provider);
921        }
922        if !harness_candidates.is_empty() {
923            println!("Candidates: {}", harness_candidates.join(", "));
924        }
925    }
926
927    Ok(0)
928}
929
930// ---------------------------------------------------------------------------
931// Helpers
932// ---------------------------------------------------------------------------
933
934/// Load model aliases by combining cached dependency aliases with consumer config.
935fn load_merged_aliases(
936    ctx: &MarsContext,
937) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
938    // Start with builtins (lowest precedence)
939    let mut merged = models::builtin_aliases();
940
941    // Layer dep aliases from cached merge file (overrides builtins)
942    let mars_dir = ctx.project_root.join(".mars");
943    let merged_path = mars_dir.join("models-merged.json");
944    if let Ok(content) = std::fs::read_to_string(&merged_path)
945        && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
946    {
947        for (name, alias) in cached {
948            merged.insert(name, alias);
949        }
950    }
951
952    // Layer consumer config on top (highest precedence)
953    if let Ok(config) = crate::config::load(&ctx.project_root) {
954        for (name, alias) in &config.models {
955            merged.insert(name.clone(), alias.clone());
956        }
957    }
958
959    Ok(merged)
960}
961
962/// Determine which layer provides an alias (consumer or dependency).
963fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
964    let config = match crate::config::load(&ctx.project_root) {
965        Ok(c) => c,
966        Err(_) => return Ok("unknown".to_string()),
967    };
968
969    if config.models.contains_key(name) {
970        return Ok("consumer (mars.toml)".to_string());
971    }
972
973    Ok("dependency".to_string())
974}
975
976fn format_spec(spec: &ModelSpec) -> serde_json::Value {
977    match spec {
978        ModelSpec::Pinned { model, provider } => {
979            let mut out = serde_json::json!({ "mode": "pinned", "model": model });
980            if let Some(provider) = provider {
981                out["provider"] = serde_json::json!(provider);
982            }
983            out
984        }
985        ModelSpec::PinnedWithMatch {
986            model,
987            provider,
988            match_patterns,
989            exclude_patterns,
990        } => {
991            let mut out = serde_json::json!({
992                "mode": "pinned",
993                "model": model,
994                "match": match_patterns,
995                "exclude": exclude_patterns,
996            });
997            if let Some(provider) = provider {
998                out["provider"] = serde_json::json!(provider);
999            }
1000            out
1001        }
1002        ModelSpec::AutoResolve {
1003            provider,
1004            match_patterns,
1005            exclude_patterns,
1006        } => {
1007            serde_json::json!({
1008                "mode": "auto-resolve",
1009                "provider": provider,
1010                "match": match_patterns,
1011                "exclude": exclude_patterns,
1012            })
1013        }
1014    }
1015}
1016
1017fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
1018    match spec {
1019        Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
1020        Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
1021        None => "unknown",
1022    }
1023}
1024
1025fn harness_source_label(source: &HarnessSource) -> &'static str {
1026    match source {
1027        HarnessSource::Explicit => "explicit",
1028        HarnessSource::AutoDetected => "auto-detected",
1029        HarnessSource::Unavailable => "unavailable",
1030    }
1031}
1032
1033fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
1034    if resolved.harness_source != HarnessSource::Unavailable {
1035        return None;
1036    }
1037    if let Some(h) = &resolved.harness {
1038        Some(format!("Harness '{}' is not installed", h))
1039    } else {
1040        Some(format!(
1041            "No installed harness for provider '{}'. Install one of: {}",
1042            resolved.provider,
1043            resolved.harness_candidates.join(", ")
1044        ))
1045    }
1046}
1047
1048fn stale_warning(reason: &str) -> String {
1049    format!("models cache refresh failed: {reason}; using stale cache")
1050}
1051
1052fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
1053    match outcome {
1054        models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
1055        _ => None,
1056    }
1057}
1058
1059fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
1060    diagnostics
1061        .iter()
1062        .map(|diagnostic| {
1063            serde_json::json!({
1064                "level": diagnostic_level_label(diagnostic.level),
1065                "code": diagnostic.code,
1066                "message": diagnostic.message,
1067                "context": diagnostic.context,
1068            })
1069        })
1070        .collect()
1071}
1072
1073fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
1074    let diagnostics = diag.drain();
1075    if diagnostics.is_empty() {
1076        None
1077    } else {
1078        Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
1079    }
1080}
1081
1082fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
1083    for diagnostic in diagnostics {
1084        let label = diagnostic_level_label(diagnostic.level);
1085        eprintln!("{label}: {}", diagnostic.message);
1086    }
1087}
1088
1089fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
1090    let diagnostics = diag.drain();
1091    emit_drained_text_diagnostics(&diagnostics);
1092}
1093
1094fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
1095    match level {
1096        DiagnosticLevel::Warning => "warning",
1097        DiagnosticLevel::Info => "info",
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104    use clap::Parser;
1105    use indexmap::IndexMap;
1106    use tempfile::TempDir;
1107
1108    fn write_mars_toml(temp: &TempDir, contents: &str) {
1109        std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
1110    }
1111
1112    fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
1113        match result {
1114            Ok(code) => code,
1115            Err(err) => err.exit_code(),
1116        }
1117    }
1118
1119    #[test]
1120    fn list_args_parses_no_refresh_models() {
1121        let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
1122        assert!(args.no_refresh_models);
1123    }
1124
1125    #[test]
1126    fn list_args_parses_catalog() {
1127        let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
1128        assert!(args.catalog);
1129    }
1130
1131    #[test]
1132    fn list_all_and_catalog_conflict() {
1133        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
1134        assert!(parsed.is_err());
1135    }
1136
1137    #[test]
1138    fn list_all_and_include_conflict() {
1139        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
1140        assert!(parsed.is_err());
1141    }
1142
1143    #[test]
1144    fn list_catalog_and_include_conflict() {
1145        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
1146        assert!(parsed.is_err());
1147    }
1148
1149    #[test]
1150    fn resolve_alias_args_parses_no_refresh_models() {
1151        let args =
1152            ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
1153        assert!(args.no_refresh_models);
1154    }
1155
1156    #[test]
1157    fn list_no_refresh_without_cache_is_non_zero() {
1158        let temp = TempDir::new().unwrap();
1159        write_mars_toml(&temp, "[settings]\n");
1160        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1161        let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
1162
1163        let exit = normalized_exit_code(run(&args, &ctx, false));
1164        assert_ne!(exit, 0);
1165    }
1166
1167    #[test]
1168    fn resolve_no_refresh_without_cache_is_non_zero() {
1169        let temp = TempDir::new().unwrap();
1170        write_mars_toml(
1171            &temp,
1172            r#"[settings]
1173
1174[models.opus]
1175harness = "claude"
1176model = "claude-opus-4-6"
1177"#,
1178        );
1179        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1180        let args =
1181            ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
1182
1183        let exit = normalized_exit_code(run(&args, &ctx, false));
1184        assert_ne!(exit, 0);
1185    }
1186
1187    #[test]
1188    fn alias_updates_existing_model_entry() {
1189        let temp = TempDir::new().unwrap();
1190        write_mars_toml(
1191            &temp,
1192            r#"[settings]
1193
1194[models.fast]
1195harness = "claude"
1196model = "claude-3-5-sonnet"
1197description = "Old alias"
1198"#,
1199        );
1200        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1201
1202        let args = AddAliasArgs {
1203            name: "fast".to_string(),
1204            model_id: "gpt-5.3-codex".to_string(),
1205            harness: "codex".to_string(),
1206            description: Some("Updated alias".to_string()),
1207        };
1208
1209        let exit = run_alias(&args, &ctx, false).unwrap();
1210        assert_eq!(exit, 0);
1211
1212        let config = crate::config::load(temp.path()).unwrap();
1213        assert_eq!(config.models.len(), 1);
1214
1215        let alias = config.models.get("fast").unwrap();
1216        assert_eq!(alias.harness.as_deref(), Some("codex"));
1217        assert_eq!(alias.description.as_deref(), Some("Updated alias"));
1218        match &alias.spec {
1219            ModelSpec::Pinned { model, provider } => {
1220                assert_eq!(model, "gpt-5.3-codex");
1221                assert_eq!(provider, &None);
1222            }
1223            _ => panic!("expected pinned alias"),
1224        }
1225    }
1226
1227    fn auto_alias(
1228        provider: &str,
1229        match_patterns: &[&str],
1230        exclude_patterns: &[&str],
1231    ) -> ModelAlias {
1232        ModelAlias {
1233            harness: None,
1234            description: None,
1235            default_effort: None,
1236            autocompact: None,
1237            spec: ModelSpec::AutoResolve {
1238                provider: provider.to_string(),
1239                match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1240                exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1241            },
1242        }
1243    }
1244
1245    fn pinned_with_match_alias(
1246        model: &str,
1247        provider: &str,
1248        match_patterns: &[&str],
1249        exclude_patterns: &[&str],
1250    ) -> ModelAlias {
1251        ModelAlias {
1252            harness: None,
1253            description: None,
1254            default_effort: None,
1255            autocompact: None,
1256            spec: ModelSpec::PinnedWithMatch {
1257                model: model.to_string(),
1258                provider: Some(provider.to_string()),
1259                match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1260                exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1261            },
1262        }
1263    }
1264
1265    fn pinned_alias(model: &str) -> ModelAlias {
1266        ModelAlias {
1267            harness: None,
1268            description: None,
1269            default_effort: None,
1270            autocompact: None,
1271            spec: ModelSpec::Pinned {
1272                model: model.to_string(),
1273                provider: None,
1274            },
1275        }
1276    }
1277
1278    fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
1279        ModelAlias {
1280            harness: None,
1281            description: None,
1282            default_effort: None,
1283            autocompact: None,
1284            spec: ModelSpec::Pinned {
1285                model: model.to_string(),
1286                provider: Some(provider.to_string()),
1287            },
1288        }
1289    }
1290
1291    fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
1292        models::CachedModel {
1293            id: id.to_string(),
1294            provider: provider.to_string(),
1295            release_date: release_date.map(|value| value.to_string()),
1296            description: Some(format!("desc-{id}")),
1297            context_window: None,
1298            max_output: None,
1299        }
1300    }
1301
1302    fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
1303        models::ModelsCache {
1304            models,
1305            fetched_at: Some("123".to_string()),
1306        }
1307    }
1308
1309    #[test]
1310    fn list_all_shows_multiple_per_alias() {
1311        let mut merged = IndexMap::new();
1312        merged.insert(
1313            "opus".to_string(),
1314            auto_alias("Anthropic", &["claude-opus-*"], &[]),
1315        );
1316
1317        let models_cache = cache(vec![
1318            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1319            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
1320        ]);
1321
1322        let rows = collect_all_model_entries(&merged, &models_cache);
1323        assert_eq!(rows.len(), 2);
1324        assert_eq!(rows[0].id, "claude-opus-4-7");
1325        assert_eq!(rows[1].id, "claude-opus-4-6");
1326    }
1327
1328    #[test]
1329    fn list_all_includes_matched_aliases_with_dedup() {
1330        let mut merged = IndexMap::new();
1331        merged.insert(
1332            "opus".to_string(),
1333            auto_alias("Anthropic", &["claude-opus-*"], &[]),
1334        );
1335        merged.insert(
1336            "legacy".to_string(),
1337            auto_alias("Anthropic", &["*4-6"], &[]),
1338        );
1339
1340        let models_cache = cache(vec![cached_model(
1341            "claude-opus-4-6",
1342            "Anthropic",
1343            Some("2026-02-05"),
1344        )]);
1345
1346        let rows = collect_all_model_entries(&merged, &models_cache);
1347        assert_eq!(rows.len(), 1);
1348        assert_eq!(rows[0].id, "claude-opus-4-6");
1349        assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
1350    }
1351
1352    #[test]
1353    fn list_all_includes_pinned_cache_entries() {
1354        let mut merged = IndexMap::new();
1355        merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1356
1357        let models_cache = cache(vec![cached_model(
1358            "gpt-5.3-codex",
1359            "OpenAI",
1360            Some("2026-01-01"),
1361        )]);
1362        let rows = collect_all_model_entries(&merged, &models_cache);
1363        assert_eq!(rows.len(), 1);
1364        assert_eq!(rows[0].id, "gpt-5.3-codex");
1365        assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1366    }
1367
1368    #[test]
1369    fn list_all_includes_pinned_cache_miss_entries() {
1370        let mut merged = IndexMap::new();
1371        merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1372
1373        let models_cache = cache(Vec::new());
1374        let rows = collect_all_model_entries(&merged, &models_cache);
1375        assert_eq!(rows.len(), 1);
1376        assert_eq!(rows[0].id, "gpt-5.3-codex");
1377        assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
1378        assert_eq!(rows[0].release_date, None);
1379        assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1380    }
1381
1382    #[test]
1383    fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
1384        let mut merged = IndexMap::new();
1385        merged.insert(
1386            "custom".to_string(),
1387            pinned_alias_with_provider("custom-model-id", "Anthropic"),
1388        );
1389
1390        let models_cache = cache(Vec::new());
1391        let rows = collect_all_model_entries(&merged, &models_cache);
1392        assert_eq!(rows.len(), 1);
1393        assert_eq!(rows[0].id, "custom-model-id");
1394        assert_eq!(rows[0].provider, "Anthropic");
1395        assert_eq!(rows[0].release_date, None);
1396        assert_eq!(rows[0].matched_aliases, vec!["custom"]);
1397    }
1398
1399    #[test]
1400    fn list_all_includes_unavailable_harness_entries() {
1401        let mut merged = IndexMap::new();
1402        merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
1403        let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
1404
1405        let rows = collect_all_model_entries(&merged, &models_cache);
1406        assert_eq!(rows.len(), 1);
1407        assert_eq!(rows[0].harness, None);
1408        assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
1409        assert!(rows[0].harness_candidates.is_empty());
1410    }
1411
1412    #[test]
1413    fn list_catalog_shows_all_cache_sorted() {
1414        let models_cache = cache(vec![
1415            cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
1416            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1417            cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
1418        ]);
1419
1420        let rows = collect_catalog_model_entries(&models_cache);
1421        assert_eq!(rows.len(), 3);
1422        assert_eq!(rows[0].id, "claude-opus-4-6");
1423        assert_eq!(rows[1].id, "claude-sonnet-4-5");
1424        assert_eq!(rows[2].id, "gpt-5");
1425    }
1426
1427    #[test]
1428    fn list_all_includes_pinned_with_match_discovery_candidates() {
1429        let mut merged = IndexMap::new();
1430        merged.insert(
1431            "opus".to_string(),
1432            pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1433        );
1434        let models_cache = cache(vec![
1435            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1436            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1437        ]);
1438
1439        let rows = collect_all_model_entries(&merged, &models_cache);
1440        assert_eq!(rows.len(), 2);
1441        assert_eq!(rows[0].id, "claude-opus-4-7");
1442        assert_eq!(rows[1].id, "claude-opus-4-6");
1443        assert_eq!(rows[0].matched_aliases, vec!["opus"]);
1444        assert_eq!(rows[1].matched_aliases, vec!["opus"]);
1445    }
1446
1447    #[test]
1448    fn resolve_pinned_with_match_uses_model_field() {
1449        let mut merged = IndexMap::new();
1450        merged.insert(
1451            "opus".to_string(),
1452            pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1453        );
1454        let models_cache = cache(vec![
1455            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1456            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1457        ]);
1458        let mut diag = DiagnosticCollector::new();
1459        let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
1460        assert_eq!(resolved.model_id, "claude-opus-4-6");
1461        assert!(diag.drain().is_empty());
1462    }
1463}