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