Skip to main content

mars_agents/models/
mod.rs

1//! Model catalog — aliases with direct model pinning and optional discovery filters,
2//! dependency-tree config merge, and models cache lifecycle.
3//!
4//! Model aliases map short names (opus, sonnet, codex) to concrete model IDs.
5//! Two modes:
6//! - **Pinned**: explicit model ID, with optional `match`/`exclude` discovery filters.
7//! - **AutoResolve**: pattern-based resolution against a cached model catalog.
8//!
9//! Merge precedence: consumer > deps (declaration order).
10
11use std::collections::HashSet;
12use std::path::Path;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18use crate::diagnostic::DiagnosticCollector;
19use crate::error::MarsError;
20use crate::types::MarsContext;
21
22pub mod availability;
23pub mod harness;
24pub mod probes;
25
26pub use availability::ModelAvailability;
27
28mod tracing {
29    macro_rules! debug {
30        ($($arg:tt)*) => {
31            if cfg!(debug_assertions) {
32                eprintln!($($arg)*);
33            }
34        };
35    }
36
37    pub(super) use debug;
38}
39
40// ---------------------------------------------------------------------------
41// Core types
42// ---------------------------------------------------------------------------
43
44/// A model alias — either pinned to a specific model ID or auto-resolved
45/// against the models cache at resolution time.
46#[derive(Debug, Clone, PartialEq, Serialize)]
47pub struct ModelAlias {
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub harness: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub description: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub default_effort: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub autocompact: Option<u32>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub autocompact_pct: Option<u8>,
58    #[serde(flatten)]
59    pub spec: ModelSpec,
60}
61
62/// How a model alias resolves to a concrete model ID.
63#[derive(Debug, Clone, PartialEq)]
64pub enum ModelSpec {
65    /// Explicit model ID — no resolution needed.
66    Pinned {
67        model: String,
68        provider: Option<String>,
69    },
70    /// Explicit model ID for resolution, plus discovery filters for list/all views.
71    PinnedWithMatch {
72        model: String,
73        provider: Option<String>,
74        match_patterns: Vec<String>,
75        exclude_patterns: Vec<String>,
76    },
77    /// Pattern-based resolution against models cache.
78    AutoResolve {
79        provider: String,
80        match_patterns: Vec<String>,
81        exclude_patterns: Vec<String>,
82    },
83}
84
85/// How the harness was determined.
86#[derive(Debug, Clone, PartialEq, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub enum HarnessSource {
89    Explicit,
90    AutoDetected,
91    Unavailable,
92}
93
94/// Fully resolved model alias — everything a consumer needs to launch.
95#[derive(Debug, Clone, Serialize)]
96pub struct ResolvedAlias {
97    pub name: String,
98    pub model_id: String,
99    pub provider: String,
100    pub harness: Option<String>,
101    pub harness_source: HarnessSource,
102    pub harness_candidates: Vec<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub description: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub default_effort: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub autocompact: Option<u32>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub autocompact_pct: Option<u8>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub availability: Option<ModelAvailability>,
113}
114
115// Custom Serialize for ModelSpec to flatten into parent
116impl Serialize for ModelSpec {
117    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
118        use serde::ser::SerializeMap;
119        match self {
120            ModelSpec::Pinned { model, provider } => {
121                let mut count = 1;
122                if provider.is_some() {
123                    count += 1;
124                }
125                let mut map = serializer.serialize_map(Some(count))?;
126                map.serialize_entry("model", model)?;
127                if let Some(provider) = provider {
128                    map.serialize_entry("provider", provider)?;
129                }
130                map.end()
131            }
132            ModelSpec::PinnedWithMatch {
133                model,
134                provider,
135                match_patterns,
136                exclude_patterns,
137            } => {
138                let mut count = 2; // model + match
139                if provider.is_some() {
140                    count += 1;
141                }
142                if !exclude_patterns.is_empty() {
143                    count += 1;
144                }
145                let mut map = serializer.serialize_map(Some(count))?;
146                map.serialize_entry("model", model)?;
147                map.serialize_entry("match", match_patterns)?;
148                if let Some(provider) = provider {
149                    map.serialize_entry("provider", provider)?;
150                }
151                if !exclude_patterns.is_empty() {
152                    map.serialize_entry("exclude", exclude_patterns)?;
153                }
154                map.end()
155            }
156            ModelSpec::AutoResolve {
157                provider,
158                match_patterns,
159                exclude_patterns,
160            } => {
161                let mut count = 2; // provider + match
162                if !exclude_patterns.is_empty() {
163                    count += 1;
164                }
165                let mut map = serializer.serialize_map(Some(count))?;
166                map.serialize_entry("provider", provider)?;
167                map.serialize_entry("match", match_patterns)?;
168                if !exclude_patterns.is_empty() {
169                    map.serialize_entry("exclude", exclude_patterns)?;
170                }
171                map.end()
172            }
173        }
174    }
175}
176
177/// Raw deserialization helper — distinguished by field presence.
178#[derive(Debug, Deserialize)]
179struct RawModelAlias {
180    harness: Option<String>,
181    #[serde(default)]
182    description: Option<String>,
183    #[serde(default)]
184    default_effort: Option<String>,
185    #[serde(default)]
186    autocompact: Option<toml::Value>,
187    #[serde(default)]
188    autocompact_pct: Option<toml::Value>,
189    // Pinned mode
190    #[serde(default)]
191    model: Option<String>,
192    // AutoResolve mode
193    #[serde(default)]
194    provider: Option<String>,
195    #[serde(default, rename = "match")]
196    match_patterns: Option<Vec<String>>,
197    #[serde(default)]
198    exclude: Option<Vec<String>>,
199}
200
201impl<'de> Deserialize<'de> for ModelAlias {
202    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
203        let raw = RawModelAlias::deserialize(deserializer)?;
204        let default_effort = raw.default_effort.filter(|value| !value.trim().is_empty());
205        if let Some(ref effort) = default_effort {
206            const VALID_EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "auto"];
207            if !VALID_EFFORTS.contains(&effort.as_str()) {
208                return Err(serde::de::Error::custom(format!(
209                    "invalid default_effort '{effort}'; accepted values: {}",
210                    VALID_EFFORTS.join(", ")
211                )));
212            }
213        }
214        let autocompact: Option<u32> = match raw.autocompact {
215            Some(toml::Value::Integer(value)) => match u32::try_from(value) {
216                Ok(v) => Some(v),
217                Err(_) => {
218                    return Err(serde::de::Error::custom(format!(
219                        "autocompact {value} is out of u32 range (0–4294967295)"
220                    )));
221                }
222            },
223            Some(other) => {
224                return Err(serde::de::Error::custom(format!(
225                    "autocompact must be an integer (token count), got {other:?}"
226                )));
227            }
228            None => None,
229        };
230        let autocompact_pct: Option<u8> = match raw.autocompact_pct {
231            Some(toml::Value::Integer(value)) if (1..=100).contains(&value) => Some(value as u8),
232            Some(toml::Value::Integer(value)) => {
233                return Err(serde::de::Error::custom(format!(
234                    "autocompact_pct {value} is out of range 1-100"
235                )));
236            }
237            Some(other) => {
238                return Err(serde::de::Error::custom(format!(
239                    "autocompact_pct must be an integer 1-100, got {other:?}"
240                )));
241            }
242            None => None,
243        };
244
245        let has_match = raw.match_patterns.is_some();
246
247        let spec = if let Some(model) = raw.model {
248            if !has_match && raw.exclude.is_some() {
249                return Err(serde::de::Error::custom(
250                    "model alias with 'exclude' must also include 'match'",
251                ));
252            }
253            if let Some(match_patterns) = raw.match_patterns {
254                ModelSpec::PinnedWithMatch {
255                    model,
256                    provider: raw.provider,
257                    match_patterns,
258                    exclude_patterns: raw.exclude.unwrap_or_default(),
259                }
260            } else {
261                ModelSpec::Pinned {
262                    model,
263                    provider: raw.provider,
264                }
265            }
266        } else if let Some(match_patterns) = raw.match_patterns {
267            let provider = raw.provider.ok_or_else(|| {
268                serde::de::Error::custom(
269                    "auto-resolve model alias requires 'provider' when 'match' is specified",
270                )
271            })?;
272            ModelSpec::AutoResolve {
273                provider,
274                match_patterns,
275                exclude_patterns: raw.exclude.unwrap_or_default(),
276            }
277        } else {
278            return Err(serde::de::Error::custom(
279                "model alias must have either 'model' (pinned) or 'match' (auto-resolve)",
280            ));
281        };
282
283        Ok(ModelAlias {
284            harness: raw.harness,
285            description: raw.description,
286            default_effort,
287            autocompact,
288            autocompact_pct,
289            spec,
290        })
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Models cache
296// ---------------------------------------------------------------------------
297
298/// Cached model catalog from external API.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ModelsCache {
301    pub models: Vec<CachedModel>,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub fetched_at: Option<String>,
304}
305
306/// A single model entry in the cache.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct CachedModel {
309    pub id: String,
310    pub provider: String,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub release_date: Option<String>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub description: Option<String>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub context_window: Option<u64>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub max_output: Option<u64>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub cost_input: Option<f64>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub cost_output: Option<f64>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub cost_cache_read: Option<f64>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub cost_cache_write: Option<f64>,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub cost_reasoning: Option<f64>,
329}
330
331const CACHE_FILE: &str = "models-cache.json";
332const FETCH_FAIL_MARKER_FILE: &str = ".models-cache.last-fail";
333const DEFAULT_MODELS_CACHE_TTL_HOURS: u32 = 24;
334pub(crate) const FETCH_FAIL_COOLDOWN_SECS: u64 = 300;
335const FETCH_FAIL_COOLDOWN_REASON: &str = "recent fetch attempt failed; backing off (cooldown)";
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum RefreshMode {
339    Auto,
340    Force,
341    Offline,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub enum RefreshOutcome {
346    AlreadyFresh,
347    Refreshed { models_count: usize },
348    StaleFallback { reason: String },
349    Offline,
350}
351
352pub fn now_unix_secs_value() -> u64 {
353    SystemTime::now()
354        .duration_since(UNIX_EPOCH)
355        .unwrap_or_default()
356        .as_secs()
357}
358
359pub fn now_unix_secs() -> String {
360    now_unix_secs_value().to_string()
361}
362
363pub fn is_mars_offline() -> bool {
364    match std::env::var("MARS_OFFLINE") {
365        Ok(value) => matches!(
366            value.trim().to_ascii_lowercase().as_str(),
367            "1" | "true" | "yes"
368        ),
369        Err(_) => false,
370    }
371}
372
373pub fn resolve_refresh_mode(no_refresh_flag: bool) -> RefreshMode {
374    if no_refresh_flag {
375        RefreshMode::Offline
376    } else {
377        RefreshMode::Auto
378    }
379}
380
381pub fn load_models_cache_ttl(ctx: &MarsContext) -> u32 {
382    crate::config::load(&ctx.project_root)
383        .map(|config| config.settings.models_cache_ttl_hours)
384        .unwrap_or(DEFAULT_MODELS_CACHE_TTL_HOURS)
385}
386
387fn read_cache_tolerant(mars_dir: &Path) -> ModelsCache {
388    match read_cache(mars_dir) {
389        Ok(cache) => cache,
390        Err(err) => {
391            tracing::debug!("models cache read failed, treating as empty: {err}");
392            ModelsCache {
393                models: Vec::new(),
394                fetched_at: None,
395            }
396        }
397    }
398}
399
400fn is_fresh(cache: &ModelsCache, ttl_hours: u32) -> bool {
401    if ttl_hours == 0 {
402        return false;
403    }
404    if cache.models.is_empty() {
405        return false;
406    }
407
408    let Some(fetched_str) = &cache.fetched_at else {
409        return false;
410    };
411    let Ok(fetched) = fetched_str.parse::<u64>() else {
412        return false;
413    };
414
415    let now = now_unix_secs_value();
416    if fetched > now {
417        return false;
418    }
419
420    (now - fetched) < (ttl_hours as u64) * 3600
421}
422
423fn is_usable(cache: &ModelsCache) -> bool {
424    !cache.models.is_empty()
425}
426
427fn read_fetch_fail_marker(mars_dir: &Path) -> Option<u64> {
428    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
429    let raw = std::fs::read_to_string(marker).ok()?;
430    raw.trim().parse::<u64>().ok()
431}
432
433fn write_fetch_fail_marker(mars_dir: &Path, timestamp: u64) {
434    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
435    if let Err(err) = crate::fs::atomic_write(&marker, timestamp.to_string().as_bytes()) {
436        tracing::debug!("failed to write models fetch failure marker: {err}");
437    }
438}
439
440fn clear_fetch_fail_marker(mars_dir: &Path) {
441    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
442    if let Err(err) = std::fs::remove_file(marker)
443        && err.kind() != std::io::ErrorKind::NotFound
444    {
445        tracing::debug!("failed to clear models fetch failure marker: {err}");
446    }
447}
448
449pub fn ensure_fresh(
450    mars_dir: &Path,
451    ttl_hours: u32,
452    mode: RefreshMode,
453) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
454    ensure_fresh_with_fetcher(mars_dir, ttl_hours, mode, fetch_models)
455}
456
457fn ensure_fresh_with_fetcher<F>(
458    mars_dir: &Path,
459    ttl_hours: u32,
460    mode: RefreshMode,
461    fetcher: F,
462) -> Result<(ModelsCache, RefreshOutcome), MarsError>
463where
464    F: FnOnce() -> Result<Vec<CachedModel>, MarsError>,
465{
466    std::fs::create_dir_all(mars_dir)?;
467
468    // D1: apply MARS_OFFLINE coercion exactly once here.
469    let effective_mode = match mode {
470        RefreshMode::Auto if is_mars_offline() => RefreshMode::Offline,
471        m => m,
472    };
473
474    let prior = read_cache_tolerant(mars_dir);
475
476    if effective_mode == RefreshMode::Auto && is_fresh(&prior, ttl_hours) {
477        return Ok((prior, RefreshOutcome::AlreadyFresh));
478    }
479
480    if effective_mode == RefreshMode::Offline {
481        if is_usable(&prior) {
482            return Ok((prior, RefreshOutcome::Offline));
483        }
484        return Err(MarsError::ModelCacheUnavailable {
485            reason: offline_unavailable_reason(mode),
486        });
487    }
488
489    let lock_path = mars_dir.join(".models-cache.lock");
490    let _guard = crate::fs::FileLock::acquire(&lock_path)?;
491
492    let under_lock = read_cache_tolerant(mars_dir);
493    if effective_mode == RefreshMode::Auto && is_fresh(&under_lock, ttl_hours) {
494        return Ok((under_lock, RefreshOutcome::AlreadyFresh));
495    }
496
497    if mode != RefreshMode::Force && is_usable(&under_lock) {
498        let now = now_unix_secs_value();
499        if let Some(last_fail) = read_fetch_fail_marker(mars_dir)
500            && now.saturating_sub(last_fail) < FETCH_FAIL_COOLDOWN_SECS
501        {
502            return Ok((
503                under_lock,
504                RefreshOutcome::StaleFallback {
505                    reason: FETCH_FAIL_COOLDOWN_REASON.to_string(),
506                },
507            ));
508        }
509    }
510
511    match fetcher() {
512        Ok(models) if !models.is_empty() => {
513            let models_count = models.len();
514            let cache = ModelsCache {
515                models,
516                fetched_at: Some(now_unix_secs()),
517            };
518            write_cache(mars_dir, &cache)?;
519            clear_fetch_fail_marker(mars_dir);
520            Ok((cache, RefreshOutcome::Refreshed { models_count }))
521        }
522        Ok(_) => fallback_to_stale_or_error(
523            mars_dir,
524            under_lock,
525            "API returned empty catalog".to_string(),
526            "API returned an empty catalog and no prior cache exists".to_string(),
527            true,
528        ),
529        Err(err) => fallback_to_stale_or_error(
530            mars_dir,
531            under_lock,
532            format!("fetch failed: {err}"),
533            format!("automatic refresh failed: {err}"),
534            true,
535        ),
536    }
537}
538
539fn fallback_to_stale_or_error(
540    mars_dir: &Path,
541    under_lock: ModelsCache,
542    stale_reason: String,
543    unavailable_reason: String,
544    mark_fetch_failure: bool,
545) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
546    if is_usable(&under_lock) {
547        if mark_fetch_failure {
548            write_fetch_fail_marker(mars_dir, now_unix_secs_value());
549        }
550        Ok((
551            under_lock,
552            RefreshOutcome::StaleFallback {
553                reason: stale_reason,
554            },
555        ))
556    } else {
557        Err(MarsError::ModelCacheUnavailable {
558            reason: unavailable_reason,
559        })
560    }
561}
562
563fn offline_unavailable_reason(requested_mode: RefreshMode) -> String {
564    match requested_mode {
565        RefreshMode::Offline => {
566            "--no-refresh-models was passed and no cached catalog is available".to_string()
567        }
568        RefreshMode::Auto => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
569        RefreshMode::Force => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
570    }
571}
572
573/// Read models cache from `.mars/models-cache.json`.
574pub fn read_cache(mars_dir: &Path) -> Result<ModelsCache, MarsError> {
575    let path = mars_dir.join(CACHE_FILE);
576    match std::fs::read_to_string(&path) {
577        Ok(content) => {
578            let cache: ModelsCache =
579                serde_json::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
580                    message: format!("failed to parse models cache: {e}"),
581                })?;
582            Ok(cache)
583        }
584        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsCache {
585            models: Vec::new(),
586            fetched_at: None,
587        }),
588        Err(source) => Err(MarsError::Io {
589            operation: "read models cache".to_string(),
590            path,
591            source,
592        }),
593    }
594}
595
596/// Write models cache to `.mars/models-cache.json` (atomic via tmp+rename).
597pub fn write_cache(mars_dir: &Path, cache: &ModelsCache) -> Result<(), MarsError> {
598    std::fs::create_dir_all(mars_dir)?;
599    let path = mars_dir.join(CACHE_FILE);
600    let tmp_path = mars_dir.join(".models-cache.json.tmp");
601    let content =
602        serde_json::to_string_pretty(cache).map_err(|e| crate::error::ConfigError::Invalid {
603            message: format!("failed to serialize models cache: {e}"),
604        })?;
605    std::fs::write(&tmp_path, content)?;
606    std::fs::rename(&tmp_path, &path)?;
607    Ok(())
608}
609
610/// Fetch models from the models.dev API.
611///
612/// Returns a list of cached model entries. On network failure, returns an error
613/// (callers should fall back to existing cache or explicit pinned IDs).
614pub fn fetch_models() -> Result<Vec<CachedModel>, MarsError> {
615    let url = models_api_url();
616    let agent: ureq::Agent = ureq::Agent::config_builder()
617        .timeout_connect(Some(Duration::from_secs(15)))
618        .timeout_recv_response(Some(Duration::from_secs(15)))
619        .timeout_recv_body(Some(Duration::from_secs(15)))
620        .build()
621        .into();
622
623    let response = agent.get(&url).call().map_err(|e| match e {
624        ureq::Error::StatusCode(status) => MarsError::Http {
625            url: url.clone(),
626            status,
627            message: format!("request failed with HTTP status {status}"),
628        },
629        _ => MarsError::Http {
630            url: url.clone(),
631            status: 0,
632            message: format!("failed to fetch models catalog: {e}"),
633        },
634    })?;
635    let body = response
636        .into_body()
637        .read_to_string()
638        .map_err(|e| MarsError::Http {
639            url: url.clone(),
640            status: 0,
641            message: format!("failed to read response body: {e}"),
642        })?;
643    let raw: serde_json::Value =
644        serde_json::from_str(&body).map_err(|e| crate::error::ConfigError::Invalid {
645            message: format!("failed to parse models API response: {e}"),
646        })?;
647
648    parse_models_dev_catalog(&raw)
649}
650
651fn models_api_url() -> String {
652    std::env::var("MARS_MODELS_API_URL").unwrap_or_else(|_| "https://models.dev/api.json".into())
653}
654
655fn parse_models_dev_catalog(raw: &serde_json::Value) -> Result<Vec<CachedModel>, MarsError> {
656    let providers = raw
657        .as_object()
658        .ok_or_else(|| crate::error::ConfigError::Invalid {
659            message: "models API response must be an object keyed by provider".to_string(),
660        })?;
661
662    let mut models = Vec::new();
663
664    for (provider_key, provider_obj) in providers {
665        if !is_major_provider(provider_key) {
666            continue;
667        }
668
669        let Some(provider_models) = provider_obj.get("models").and_then(|m| m.as_object()) else {
670            continue;
671        };
672
673        for model_obj in provider_models.values() {
674            let Some(model_id) = model_obj.get("id").and_then(|v| v.as_str()) else {
675                continue;
676            };
677            let release_date = model_obj
678                .get("release_date")
679                .and_then(|v| v.as_str())
680                .map(str::to_string);
681            let description = model_obj
682                .get("name")
683                .and_then(|v| v.as_str())
684                .map(str::to_string);
685            let context_window = model_obj
686                .get("limit")
687                .and_then(|v| v.get("context"))
688                .and_then(|v| v.as_u64());
689            let max_output = model_obj
690                .get("limit")
691                .and_then(|v| v.get("output"))
692                .and_then(|v| v.as_u64());
693            let cost = model_obj.get("cost");
694            let cost_input = cost.and_then(|v| v.get("input")).and_then(|v| v.as_f64());
695            let cost_output = cost.and_then(|v| v.get("output")).and_then(|v| v.as_f64());
696            let cost_cache_read = cost
697                .and_then(|v| v.get("cache_read"))
698                .and_then(|v| v.as_f64());
699            let cost_cache_write = cost
700                .and_then(|v| v.get("cache_write"))
701                .and_then(|v| v.as_f64());
702            let cost_reasoning = cost
703                .and_then(|v| v.get("reasoning"))
704                .and_then(|v| v.as_f64());
705
706            models.push(CachedModel {
707                id: model_id.to_string(),
708                provider: normalize_provider(provider_key),
709                release_date,
710                description,
711                context_window,
712                max_output,
713                cost_input,
714                cost_output,
715                cost_cache_read,
716                cost_cache_write,
717                cost_reasoning,
718            });
719        }
720    }
721
722    Ok(models)
723}
724
725fn is_major_provider(provider_key: &str) -> bool {
726    matches!(
727        provider_key,
728        "anthropic"
729            | "openai"
730            | "google"
731            | "meta-llama"
732            | "meta"
733            | "mistralai"
734            | "mistral"
735            | "deepseek"
736            | "cohere"
737    )
738}
739
740/// Normalize models.dev provider keys to canonical names.
741fn normalize_provider(slug: &str) -> String {
742    match slug {
743        "anthropic" => "Anthropic".to_string(),
744        "openai" => "OpenAI".to_string(),
745        "google" => "Google".to_string(),
746        "meta-llama" | "meta" => "Meta".to_string(),
747        "mistralai" | "mistral" => "Mistral".to_string(),
748        "deepseek" => "DeepSeek".to_string(),
749        "cohere" => "Cohere".to_string(),
750        _ => slug.to_string(),
751    }
752}
753
754// ---------------------------------------------------------------------------
755// Auto-resolve algorithm
756// ---------------------------------------------------------------------------
757
758/// Resolve an auto-resolve spec against the models cache.
759///
760/// Algorithm:
761/// 1. Filter by provider (case-insensitive)
762/// 2. All match patterns must hit (AND)
763/// 3. No exclude patterns may hit (OR)
764/// 4. Skip entries ending with `-latest` (synthetic aliases)
765/// 5. Sort by newest release_date, then shortest ID, then lexical ID
766/// 6. Return all candidates
767pub fn auto_resolve_all<'a>(
768    provider: &str,
769    match_patterns: &[String],
770    exclude_patterns: &[String],
771    cache: &'a ModelsCache,
772) -> Vec<&'a CachedModel> {
773    let mut candidates: Vec<&CachedModel> = cache
774        .models
775        .iter()
776        .filter(|m| {
777            // Provider match (case-insensitive)
778            m.provider.eq_ignore_ascii_case(provider)
779        })
780        .filter(|m| {
781            // Skip -latest suffix (synthetic aliases)
782            !m.id.ends_with("-latest")
783        })
784        .filter(|m| {
785            // All match patterns must hit (AND)
786            match_patterns.iter().all(|p| glob_match(p, &m.id))
787        })
788        .filter(|m| {
789            // No exclude patterns may hit (OR)
790            !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
791        })
792        .collect();
793
794    // Sort: newest release_date first, then shortest ID, then lexical ID.
795    candidates.sort_by(|a, b| {
796        let date_cmp = b
797            .release_date
798            .as_deref()
799            .unwrap_or("")
800            .cmp(a.release_date.as_deref().unwrap_or(""));
801        date_cmp
802            .then_with(|| a.id.len().cmp(&b.id.len()))
803            .then_with(|| a.id.cmp(&b.id))
804    });
805
806    candidates
807}
808
809/// Resolve an auto-resolve spec against the models cache.
810///
811/// Algorithm:
812/// 1. Filter by provider (case-insensitive)
813/// 2. All match patterns must hit (AND)
814/// 3. No exclude patterns may hit (OR)
815/// 4. Skip entries ending with `-latest` (synthetic aliases)
816/// 5. Sort by newest release_date, then shortest ID, then lexical ID
817/// 6. Pick first
818pub fn auto_resolve(
819    provider: &str,
820    match_patterns: &[String],
821    exclude_patterns: &[String],
822    cache: &ModelsCache,
823) -> Option<String> {
824    auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
825        .first()
826        .map(|model| model.id.clone())
827}
828
829/// Resolve an input like `opus-4-6` by matching it against alias filter candidates.
830///
831/// Algorithm:
832/// 1. Build a glob pattern `*{input}*` from the user input
833/// 2. For each auto-resolve alias, run its filters against the cache
834/// 3. From those candidates, keep models matching the glob
835/// 4. Collect union across aliases, deduplicated by model ID
836/// 5. Sort by newest release_date, then shortest ID
837/// 6. Return the best candidate
838pub fn resolve_with_alias_prefix(
839    input: &str,
840    aliases: &IndexMap<String, ModelAlias>,
841    cache: &ModelsCache,
842) -> Option<ResolvedAlias> {
843    let pattern = if input.contains('*') {
844        input.to_string()
845    } else {
846        format!("*{}*", input)
847    };
848    let base_alias = alias_prefix_base(input, aliases);
849    let mut deduped: IndexMap<String, CachedModel> = IndexMap::new();
850
851    if let Some(alias) = base_alias
852        && let Some((model, provider)) = match &alias.spec {
853            ModelSpec::Pinned { model, provider } => Some((model, provider)),
854            ModelSpec::PinnedWithMatch {
855                model, provider, ..
856            } => Some((model, provider)),
857            ModelSpec::AutoResolve { .. } => None,
858        }
859    {
860        let provider_filter = provider
861            .as_deref()
862            .or_else(|| infer_provider_from_model_id(model));
863        for candidate in &cache.models {
864            if !glob_match(&pattern, &candidate.id) {
865                continue;
866            }
867            if let Some(provider_filter) = provider_filter
868                && !candidate.provider.eq_ignore_ascii_case(provider_filter)
869            {
870                continue;
871            }
872            deduped
873                .entry(candidate.id.clone())
874                .or_insert_with(|| candidate.clone());
875        }
876    }
877
878    for (_alias_name, alias) in aliases {
879        match &alias.spec {
880            ModelSpec::AutoResolve {
881                provider,
882                match_patterns,
883                exclude_patterns,
884            } => {
885                for candidate in auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
886                {
887                    if glob_match(&pattern, &candidate.id) {
888                        deduped
889                            .entry(candidate.id.clone())
890                            .or_insert_with(|| candidate.clone());
891                    }
892                }
893            }
894            ModelSpec::PinnedWithMatch {
895                model,
896                provider,
897                match_patterns,
898                exclude_patterns,
899            } => {
900                let Some(provider) = provider
901                    .as_deref()
902                    .or_else(|| infer_provider_from_model_id(model))
903                else {
904                    continue;
905                };
906                for candidate in auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
907                {
908                    if glob_match(&pattern, &candidate.id) {
909                        deduped
910                            .entry(candidate.id.clone())
911                            .or_insert_with(|| candidate.clone());
912                    }
913                }
914            }
915            ModelSpec::Pinned { .. } => {}
916        }
917    }
918
919    let mut candidates: Vec<CachedModel> = deduped.into_values().collect();
920    candidates.sort_by(|a, b| {
921        let date_cmp = b
922            .release_date
923            .as_deref()
924            .unwrap_or("")
925            .cmp(a.release_date.as_deref().unwrap_or(""));
926        date_cmp
927            .then_with(|| a.id.len().cmp(&b.id.len()))
928            .then_with(|| a.id.cmp(&b.id))
929    });
930
931    let winner = candidates.into_iter().next()?;
932    let provider = winner.provider.to_ascii_lowercase();
933    let (default_effort, autocompact, autocompact_pct) = match base_alias {
934        Some(ModelAlias {
935            default_effort,
936            autocompact,
937            autocompact_pct,
938            spec: ModelSpec::Pinned { .. } | ModelSpec::PinnedWithMatch { .. },
939            ..
940        }) => (default_effort.clone(), *autocompact, *autocompact_pct),
941        _ => (None, None, None),
942    };
943    let installed = harness::detect_installed_harnesses();
944    let harness = harness::resolve_harness_for_provider(&provider, &installed);
945    let harness_source = if harness.is_some() {
946        HarnessSource::AutoDetected
947    } else {
948        HarnessSource::Unavailable
949    };
950
951    Some(ResolvedAlias {
952        name: input.to_string(),
953        model_id: winner.id,
954        provider: provider.clone(),
955        harness,
956        harness_source,
957        harness_candidates: harness::harness_candidates_for_provider(&provider),
958        description: winner.description,
959        default_effort,
960        autocompact,
961        autocompact_pct,
962        availability: None,
963    })
964}
965
966fn alias_prefix_base<'a>(
967    input: &str,
968    aliases: &'a IndexMap<String, ModelAlias>,
969) -> Option<&'a ModelAlias> {
970    aliases
971        .iter()
972        .filter(|(name, _)| {
973            !name.is_empty()
974                && input.len() > name.len()
975                && input.starts_with(name.as_str())
976                && input.as_bytes().get(name.len()) == Some(&b'-')
977        })
978        .max_by_key(|(name, _)| name.len())
979        .map(|(_, alias)| alias)
980}
981
982/// Simple glob matching: `*` matches any sequence of characters.
983/// Everything else is literal. Case-sensitive.
984pub fn glob_match(pattern: &str, text: &str) -> bool {
985    // Split pattern on '*' and match segments in order
986    let segments: Vec<&str> = pattern.split('*').collect();
987
988    if segments.len() == 1 {
989        // No wildcards — exact match
990        return pattern == text;
991    }
992
993    let mut pos = 0;
994
995    // First segment must be a prefix
996    if let Some(first) = segments.first()
997        && !first.is_empty()
998    {
999        if !text.starts_with(first) {
1000            return false;
1001        }
1002        pos = first.len();
1003    }
1004
1005    // Last segment must be a suffix
1006    if let Some(last) = segments.last()
1007        && !last.is_empty()
1008        && !text[pos..].ends_with(last)
1009    {
1010        return false;
1011    }
1012
1013    // Middle segments must appear in order
1014    let end = if let Some(last) = segments.last() {
1015        if !last.is_empty() {
1016            text.len() - last.len()
1017        } else {
1018            text.len()
1019        }
1020    } else {
1021        text.len()
1022    };
1023
1024    for segment in &segments[1..segments.len().saturating_sub(1)] {
1025        if segment.is_empty() {
1026            continue;
1027        }
1028        if let Some(idx) = text[pos..end].find(segment) {
1029            pos += idx + segment.len();
1030        } else {
1031            return false;
1032        }
1033    }
1034
1035    pos <= end
1036}
1037
1038/// Match a visibility pattern against a resolved model identity.
1039///
1040/// Pattern forms:
1041/// - 0 slashes: bare model ID, e.g. `gpt-5*`
1042/// - 1 slash: provider/model, e.g. `anthropic/*`
1043/// - 2 slashes: OpenCode runnable slug, e.g. `openrouter/anthropic/*`
1044pub fn matches_visibility_pattern(
1045    pattern: &str,
1046    model_id: &str,
1047    provider: &str,
1048    runnable_paths: &[availability::RunnablePath],
1049) -> bool {
1050    let pattern = pattern.to_ascii_lowercase();
1051    let slash_count = pattern.chars().filter(|c| *c == '/').count();
1052
1053    match slash_count {
1054        0 => glob_match_no_slash(&pattern, &model_id.to_ascii_lowercase()),
1055        1 => {
1056            let candidate = format!(
1057                "{}/{}",
1058                provider.to_ascii_lowercase(),
1059                model_id.to_ascii_lowercase()
1060            );
1061            glob_match_no_slash(&pattern, &candidate)
1062        }
1063        2 => runnable_paths
1064            .iter()
1065            .any(|path| glob_match_no_slash(&pattern, &path.harness_model_id.to_ascii_lowercase())),
1066        _ => false,
1067    }
1068}
1069
1070fn glob_match_no_slash(pattern: &str, text: &str) -> bool {
1071    let pattern_parts: Vec<&str> = pattern.split('*').collect();
1072    if pattern_parts.len() == 1 {
1073        return pattern == text;
1074    }
1075
1076    let mut pos = 0;
1077    for (i, part) in pattern_parts.iter().enumerate() {
1078        if part.is_empty() {
1079            continue;
1080        }
1081        let Some(found) = text[pos..].find(part) else {
1082            return false;
1083        };
1084        if i == 0 && found != 0 {
1085            return false;
1086        }
1087        if text[pos..pos + found].contains('/') {
1088            return false;
1089        }
1090        pos += found + part.len();
1091    }
1092
1093    if pattern.ends_with('*') {
1094        !text[pos..].contains('/')
1095    } else {
1096        pos == text.len()
1097    }
1098}
1099
1100// ---------------------------------------------------------------------------
1101// Builtin aliases — bare convenience mappings, no descriptions
1102// ---------------------------------------------------------------------------
1103
1104/// Minimal builtin aliases so common model names work out of the box.
1105/// No descriptions — packages layer those on top.
1106/// Precedence: consumer > deps > builtins.
1107pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
1108    let mut m = IndexMap::new();
1109    let add = |m: &mut IndexMap<String, ModelAlias>,
1110               name: &str,
1111               provider: &str,
1112               match_patterns: &[&str],
1113               exclude: &[&str]| {
1114        m.insert(
1115            name.to_string(),
1116            ModelAlias {
1117                harness: None,
1118                description: None,
1119                default_effort: None,
1120                autocompact: None,
1121                autocompact_pct: None,
1122                spec: ModelSpec::AutoResolve {
1123                    provider: provider.to_string(),
1124                    match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1125                    exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
1126                },
1127            },
1128        );
1129    };
1130    add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
1131    add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
1132    add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
1133    add(
1134        &mut m,
1135        "codex",
1136        "openai",
1137        &["*codex*"],
1138        &["*-mini", "*-spark", "*-max"],
1139    );
1140    add(
1141        &mut m,
1142        "gpt",
1143        "openai",
1144        &["gpt-5*"],
1145        &["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
1146    );
1147    add(
1148        &mut m,
1149        "gemini",
1150        "google",
1151        &["gemini*", "*pro*"],
1152        &["*-customtools"],
1153    );
1154    m
1155}
1156
1157// ---------------------------------------------------------------------------
1158// Dependency-tree merge
1159// ---------------------------------------------------------------------------
1160
1161/// Info about a resolved dependency's model config.
1162pub struct ResolvedDepModels {
1163    pub source_name: String,
1164    pub models: IndexMap<String, ModelAlias>,
1165}
1166
1167/// Merge model aliases from dependency tree.
1168///
1169/// Precedence: consumer > deps (declaration order) > builtins.
1170/// When two deps define the same alias, first in declaration order wins
1171/// with a diagnostic warning.
1172pub fn merge_model_config(
1173    consumer: &IndexMap<String, ModelAlias>,
1174    deps: &[ResolvedDepModels],
1175    diag: &mut DiagnosticCollector,
1176    cache: Option<&ModelsCache>,
1177) -> IndexMap<String, ModelAlias> {
1178    #[derive(Clone)]
1179    struct DepWinner {
1180        source_name: String,
1181        alias: ModelAlias,
1182    }
1183
1184    let mut merged = IndexMap::new();
1185    let builtins = builtin_aliases();
1186
1187    // Layer 0 (lowest): builtins
1188    for (name, alias) in &builtins {
1189        merged.insert(name.clone(), alias.clone());
1190    }
1191
1192    // Track which dep won each alias (vs builtin)
1193    let mut dep_provided: std::collections::HashMap<String, DepWinner> =
1194        std::collections::HashMap::new();
1195
1196    // Layer 1: dependencies (override builtins silently, first dep wins on conflicts)
1197    for dep in deps {
1198        for (name, alias) in &dep.models {
1199            if consumer.contains_key(name) {
1200                // Consumer will override — skip dep's version silently
1201                continue;
1202            }
1203            if let Some(winner) = dep_provided.get(name) {
1204                // Two deps define same alias — first dep wins, warn
1205                let message = if let Some(cache) = cache {
1206                    let (winner_formatted, winner_model_id) =
1207                        format_alias_resolution_for_diag(&winner.alias, &winner.source_name, cache);
1208                    let (loser_formatted, loser_model_id) =
1209                        format_alias_resolution_for_diag(alias, &dep.source_name, cache);
1210                    if winner_model_id.is_some() && winner_model_id == loser_model_id {
1211                        format!(
1212                            "model alias `{name}` defined by both `{}` and `{}` — using {} (declared first)\n  both resolve to {}\n  → add [models.{name}] to your mars.toml to resolve explicitly",
1213                            winner.source_name,
1214                            dep.source_name,
1215                            winner.source_name,
1216                            winner_model_id.unwrap_or_default(),
1217                        )
1218                    } else {
1219                        format!(
1220                            "model alias `{name}` defined by both `{}` and `{}` — using {} (declared first)\n  {winner_formatted}, {loser_formatted}\n  → add [models.{name}] to your mars.toml to resolve explicitly",
1221                            winner.source_name, dep.source_name, winner.source_name,
1222                        )
1223                    }
1224                } else {
1225                    format!(
1226                        "model alias `{name}` defined by both `{}` and `{}` — using {} (declared first)\n  → add [models.{name}] to your mars.toml to resolve explicitly",
1227                        winner.source_name, dep.source_name, winner.source_name,
1228                    )
1229                };
1230                diag.warn_with_context("model-alias-conflict", message, dep.source_name.clone());
1231            } else {
1232                // Override builtin or insert new
1233                merged.insert(name.clone(), alias.clone());
1234                dep_provided.insert(
1235                    name.clone(),
1236                    DepWinner {
1237                        source_name: dep.source_name.clone(),
1238                        alias: alias.clone(),
1239                    },
1240                );
1241            }
1242        }
1243    }
1244
1245    // Layer 2 (highest): consumer config
1246    for (name, alias) in consumer {
1247        merged.insert(name.clone(), alias.clone());
1248    }
1249
1250    merged
1251}
1252
1253/// Resolve all aliases to concrete model IDs + harnesses.
1254///
1255/// Harness detection is encapsulated — callers don't pass installed harnesses.
1256pub fn resolve_all(
1257    aliases: &IndexMap<String, ModelAlias>,
1258    cache: &ModelsCache,
1259    diag: &mut DiagnosticCollector,
1260) -> IndexMap<String, ResolvedAlias> {
1261    let _ = diag;
1262    let installed = harness::detect_installed_harnesses();
1263    let mut resolved = IndexMap::new();
1264
1265    for (name, alias) in aliases {
1266        let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
1267            continue; // unresolvable — omit
1268        };
1269
1270        let candidates = harness::harness_candidates_for_provider(&provider);
1271        let (h, source) = resolve_harness(alias, &provider, &installed);
1272
1273        resolved.insert(
1274            name.clone(),
1275            ResolvedAlias {
1276                name: name.clone(),
1277                model_id,
1278                provider,
1279                harness: h,
1280                harness_source: source,
1281                harness_candidates: candidates,
1282                description: alias.description.clone(),
1283                default_effort: alias.default_effort.clone(),
1284                autocompact: alias.autocompact,
1285                autocompact_pct: alias.autocompact_pct,
1286                availability: None,
1287            },
1288        );
1289    }
1290
1291    resolved
1292}
1293
1294/// Resolve a single alias and emit diagnostics only for that alias.
1295pub fn resolve_one(
1296    name: &str,
1297    aliases: &IndexMap<String, ModelAlias>,
1298    cache: &ModelsCache,
1299    diag: &mut DiagnosticCollector,
1300) -> Option<ResolvedAlias> {
1301    let alias = aliases.get(name)?;
1302    let installed = harness::detect_installed_harnesses();
1303    let (model_id, provider) = resolve_model_and_provider(alias, cache)?;
1304    let candidates = harness::harness_candidates_for_provider(&provider);
1305    let (harness, harness_source) = resolve_harness(alias, &provider, &installed);
1306    let _ = diag;
1307    Some(ResolvedAlias {
1308        name: name.to_string(),
1309        model_id,
1310        provider,
1311        harness,
1312        harness_source,
1313        harness_candidates: candidates,
1314        description: alias.description.clone(),
1315        default_effort: alias.default_effort.clone(),
1316        autocompact: alias.autocompact,
1317        autocompact_pct: alias.autocompact_pct,
1318        availability: None,
1319    })
1320}
1321
1322/// Filter resolved aliases by visibility config.
1323/// - `include` patterns: keep only aliases where at least one pattern matches
1324/// - `exclude` patterns: remove aliases where any pattern matches
1325/// - No config (both None): return all aliases unchanged
1326pub fn filter_by_visibility(
1327    mut aliases: IndexMap<String, ResolvedAlias>,
1328    visibility: &crate::config::ModelVisibility,
1329) -> IndexMap<String, ResolvedAlias> {
1330    let include = visibility
1331        .include
1332        .as_ref()
1333        .filter(|patterns| !patterns.is_empty());
1334    let exclude = visibility
1335        .exclude
1336        .as_ref()
1337        .filter(|patterns| !patterns.is_empty());
1338
1339    if include.is_none() && exclude.is_none() {
1340        return aliases;
1341    }
1342
1343    if let Some(includes) = include {
1344        aliases.retain(|_, alias| {
1345            let paths = alias
1346                .availability
1347                .as_ref()
1348                .map(|availability| availability.runnable_paths.as_slice())
1349                .unwrap_or(&[]);
1350            includes.iter().any(|pattern| {
1351                matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1352            })
1353        });
1354    }
1355
1356    if let Some(excludes) = exclude {
1357        aliases.retain(|_, alias| {
1358            let paths = alias
1359                .availability
1360                .as_ref()
1361                .map(|availability| availability.runnable_paths.as_slice())
1362                .unwrap_or(&[]);
1363            !excludes.iter().any(|pattern| {
1364                matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1365            })
1366        });
1367    }
1368    aliases
1369}
1370
1371fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
1372    match &alias.spec {
1373        ModelSpec::Pinned {
1374            model, provider, ..
1375        } => {
1376            let p = provider
1377                .clone()
1378                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1379                .unwrap_or_else(|| "unknown".to_string());
1380            Some((model.clone(), p))
1381        }
1382        ModelSpec::PinnedWithMatch {
1383            model, provider, ..
1384        } => {
1385            let p = provider
1386                .clone()
1387                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1388                .unwrap_or_else(|| "unknown".to_string());
1389            Some((model.clone(), p))
1390        }
1391        ModelSpec::AutoResolve {
1392            provider,
1393            match_patterns,
1394            exclude_patterns,
1395        } => {
1396            let model_id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
1397            Some((model_id, provider.clone()))
1398        }
1399    }
1400}
1401
1402fn format_alias_resolution_for_diag(
1403    alias: &ModelAlias,
1404    source_name: &str,
1405    cache: &ModelsCache,
1406) -> (String, Option<String>) {
1407    match &alias.spec {
1408        ModelSpec::Pinned { model, .. } => (
1409            format!("{source_name} → {model} (pinned)"),
1410            Some(model.clone()),
1411        ),
1412        ModelSpec::PinnedWithMatch { model, .. } => (
1413            format!("{source_name} → {model} (pinned+match)"),
1414            Some(model.clone()),
1415        ),
1416        ModelSpec::AutoResolve {
1417            provider,
1418            match_patterns,
1419            exclude_patterns,
1420        } => {
1421            let resolved = auto_resolve(provider, match_patterns, exclude_patterns, cache);
1422            match resolved {
1423                Some(model_id) => (format!("{source_name} → {model_id}"), Some(model_id)),
1424                None => (format!("{source_name} → <unresolvable>"), None),
1425            }
1426        }
1427    }
1428}
1429
1430fn resolve_harness(
1431    alias: &ModelAlias,
1432    provider: &str,
1433    installed: &HashSet<String>,
1434) -> (Option<String>, HarnessSource) {
1435    if let Some(h) = &alias.harness {
1436        if installed.contains(h) {
1437            (Some(h.clone()), HarnessSource::Explicit)
1438        } else {
1439            (Some(h.clone()), HarnessSource::Unavailable)
1440        }
1441    } else {
1442        match harness::resolve_harness_for_provider(provider, installed) {
1443            Some(h) => (Some(h), HarnessSource::AutoDetected),
1444            None => (None, HarnessSource::Unavailable),
1445        }
1446    }
1447}
1448
1449/// Best-effort provider inference from model ID prefixes.
1450/// Returns None for unrecognized patterns.
1451pub fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
1452    let id = model_id.to_lowercase();
1453    if id.starts_with("claude-") {
1454        return Some("anthropic");
1455    }
1456    if id.starts_with("gpt-")
1457        || id.starts_with("o1")
1458        || id.starts_with("o3")
1459        || id.starts_with("o4")
1460        || id.starts_with("codex-")
1461    {
1462        return Some("openai");
1463    }
1464    if id.starts_with("gemini") {
1465        return Some("google");
1466    }
1467    if id.starts_with("llama") {
1468        return Some("meta");
1469    }
1470    if id.starts_with("mistral") || id.starts_with("codestral") {
1471        return Some("mistral");
1472    }
1473    if id.starts_with("deepseek") {
1474        return Some("deepseek");
1475    }
1476    if id.starts_with("command") {
1477        return Some("cohere");
1478    }
1479    None
1480}
1481
1482// ---------------------------------------------------------------------------
1483// Tests
1484// ---------------------------------------------------------------------------
1485
1486#[cfg(test)]
1487mod tests {
1488    use super::*;
1489    use httpmock::prelude::*;
1490    use std::collections::HashSet;
1491    use std::sync::atomic::{AtomicUsize, Ordering};
1492    use std::sync::{Arc, mpsc};
1493    use std::thread;
1494    use tempfile::tempdir;
1495
1496    use serial_test::serial;
1497
1498    #[test]
1499    fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
1500        let raw = serde_json::json!({
1501            "anthropic": {
1502                "models": {
1503                    "claude-opus-4-6": {
1504                        "id": "claude-opus-4-6",
1505                        "name": "Claude Opus 4.6",
1506                        "release_date": "2026-02-05",
1507                        "limit": {
1508                            "context": 1000000,
1509                            "output": 128000
1510                        },
1511                        "cost": {
1512                            "input": 5.0,
1513                            "output": 25.0,
1514                            "cache_read": 0.5,
1515                            "cache_write": 6.25,
1516                            "reasoning": 15.0
1517                        }
1518                    }
1519                }
1520            },
1521            "openai": {
1522                "models": {
1523                    "gpt-5": {
1524                        "id": "gpt-5",
1525                        "name": "GPT-5"
1526                    }
1527                }
1528            },
1529            "random-host": {
1530                "models": {
1531                    "foo": {
1532                        "id": "foo"
1533                    }
1534                }
1535            }
1536        });
1537
1538        let models = parse_models_dev_catalog(&raw).unwrap();
1539        assert_eq!(models.len(), 2);
1540
1541        let opus = models
1542            .iter()
1543            .find(|m| m.id == "claude-opus-4-6")
1544            .expect("missing claude-opus-4-6");
1545        assert_eq!(opus.provider, "Anthropic");
1546        assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1547        assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1548        assert_eq!(opus.context_window, Some(1_000_000));
1549        assert_eq!(opus.max_output, Some(128_000));
1550        assert_eq!(opus.cost_input, Some(5.0));
1551        assert_eq!(opus.cost_output, Some(25.0));
1552        assert_eq!(opus.cost_cache_read, Some(0.5));
1553        assert_eq!(opus.cost_cache_write, Some(6.25));
1554        assert_eq!(opus.cost_reasoning, Some(15.0));
1555
1556        let gpt = models
1557            .iter()
1558            .find(|m| m.id == "gpt-5")
1559            .expect("missing gpt-5");
1560        assert_eq!(gpt.provider, "OpenAI");
1561        assert_eq!(gpt.release_date, None);
1562        assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1563        assert_eq!(gpt.context_window, None);
1564        assert_eq!(gpt.max_output, None);
1565        assert_eq!(gpt.cost_input, None);
1566        assert_eq!(gpt.cost_output, None);
1567        assert_eq!(gpt.cost_cache_read, None);
1568        assert_eq!(gpt.cost_cache_write, None);
1569        assert_eq!(gpt.cost_reasoning, None);
1570    }
1571
1572    #[test]
1573    fn parse_models_dev_catalog_requires_object_root() {
1574        let raw = serde_json::json!(["not", "an", "object"]);
1575        let err = parse_models_dev_catalog(&raw).unwrap_err();
1576        assert!(err.to_string().contains("keyed by provider"));
1577    }
1578
1579    // -- glob_match tests --
1580
1581    #[test]
1582    fn glob_exact_match() {
1583        assert!(glob_match("claude-opus-4", "claude-opus-4"));
1584        assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1585    }
1586
1587    #[test]
1588    fn glob_star_suffix() {
1589        assert!(glob_match("claude-opus-*", "claude-opus-4"));
1590        assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1591        assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1592    }
1593
1594    #[test]
1595    fn glob_star_prefix() {
1596        assert!(glob_match("*-opus-4", "claude-opus-4"));
1597        assert!(!glob_match("*-opus-4", "claude-opus-5"));
1598    }
1599
1600    #[test]
1601    fn glob_star_middle() {
1602        assert!(glob_match("claude-*-4", "claude-opus-4"));
1603        assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1604        assert!(!glob_match("claude-*-4", "claude-opus-5"));
1605    }
1606
1607    #[test]
1608    fn glob_multiple_stars() {
1609        assert!(glob_match("*claude*opus*", "claude-opus-4"));
1610        assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1611        assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1612    }
1613
1614    #[test]
1615    fn glob_star_only() {
1616        assert!(glob_match("*", "anything"));
1617        assert!(glob_match("*", ""));
1618    }
1619
1620    #[test]
1621    fn glob_empty_pattern() {
1622        assert!(glob_match("", ""));
1623        assert!(!glob_match("", "something"));
1624    }
1625
1626    // -- auto_resolve tests --
1627
1628    fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1629        ModelsCache {
1630            models: models
1631                .into_iter()
1632                .map(|(id, provider, date)| CachedModel {
1633                    id: id.to_string(),
1634                    provider: provider.to_string(),
1635                    release_date: date.map(String::from),
1636                    description: None,
1637                    context_window: None,
1638                    max_output: None,
1639                    cost_input: None,
1640                    cost_output: None,
1641                    cost_cache_read: None,
1642                    cost_cache_write: None,
1643                    cost_reasoning: None,
1644                })
1645                .collect(),
1646            fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1647        }
1648    }
1649
1650    #[test]
1651    fn auto_resolve_basic() {
1652        let cache = make_cache(vec![
1653            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1654            ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1655            ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1656        ]);
1657
1658        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1659        // Newest date wins
1660        assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
1661    }
1662
1663    #[test]
1664    fn auto_resolve_exclude() {
1665        let cache = make_cache(vec![
1666            ("gpt-5", "OpenAI", Some("2025-06-01")),
1667            ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
1668            ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
1669        ]);
1670
1671        let result = auto_resolve(
1672            "OpenAI",
1673            &["gpt-*".to_string()],
1674            &["gpt-3*".to_string(), "gpt-4o*".to_string()],
1675            &cache,
1676        );
1677        assert_eq!(result, Some("gpt-5".to_string()));
1678    }
1679
1680    #[test]
1681    fn auto_resolve_skip_latest() {
1682        let cache = make_cache(vec![
1683            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1684            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1685        ]);
1686
1687        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1688        // Should skip -latest even though it has a newer date
1689        assert_eq!(result, Some("claude-opus-4".to_string()));
1690    }
1691
1692    #[test]
1693    fn auto_resolve_empty_cache() {
1694        let cache = ModelsCache {
1695            models: Vec::new(),
1696            fetched_at: None,
1697        };
1698
1699        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1700        assert_eq!(result, None);
1701    }
1702
1703    #[test]
1704    fn auto_resolve_no_match() {
1705        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1706
1707        let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
1708        assert_eq!(result, None);
1709    }
1710
1711    #[test]
1712    fn auto_resolve_provider_case_insensitive() {
1713        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1714
1715        let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
1716        assert_eq!(result, Some("claude-opus-4".to_string()));
1717    }
1718
1719    #[test]
1720    fn auto_resolve_shortest_id_tiebreaker() {
1721        let cache = make_cache(vec![
1722            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1723            ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
1724        ]);
1725
1726        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1727        // Same date — shorter ID wins
1728        assert_eq!(result, Some("claude-opus-4".to_string()));
1729    }
1730
1731    #[test]
1732    fn auto_resolve_lexical_id_tiebreaker_when_date_and_length_equal() {
1733        let cache = make_cache(vec![
1734            ("claude-opus-4-b", "Anthropic", Some("2025-03-01")),
1735            ("claude-opus-4-a", "Anthropic", Some("2025-03-01")),
1736        ]);
1737
1738        let result = auto_resolve("Anthropic", &["claude-opus-4-*".to_string()], &[], &cache);
1739        // Same date + same length — lexical ID wins for deterministic ordering.
1740        assert_eq!(result, Some("claude-opus-4-a".to_string()));
1741    }
1742
1743    #[test]
1744    fn auto_resolve_all_returns_all_candidates() {
1745        let cache = make_cache(vec![
1746            ("claude-opus-4-5", "Anthropic", Some("2025-12-01")),
1747            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1748            ("claude-opus-4-6-long", "Anthropic", Some("2026-02-05")),
1749            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1750            ("claude-opus-3", "Anthropic", Some("2024-02-05")),
1751        ]);
1752
1753        let result = auto_resolve_all(
1754            "Anthropic",
1755            &["claude-opus-*".to_string()],
1756            &["*opus-3".to_string()],
1757            &cache,
1758        );
1759        let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
1760        assert_eq!(
1761            ids,
1762            vec!["claude-opus-4-6", "claude-opus-4-6-long", "claude-opus-4-5"]
1763        );
1764    }
1765
1766    // -- merge_model_config tests --
1767
1768    fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
1769        ModelAlias {
1770            harness: harness.map(|h| h.to_string()),
1771            description: None,
1772            default_effort: None,
1773            autocompact: None,
1774            autocompact_pct: None,
1775            spec: ModelSpec::Pinned {
1776                model: model.to_string(),
1777                provider: None,
1778            },
1779        }
1780    }
1781
1782    fn auto_alias(
1783        provider: &str,
1784        match_patterns: &[&str],
1785        exclude_patterns: &[&str],
1786    ) -> ModelAlias {
1787        ModelAlias {
1788            harness: None,
1789            description: None,
1790            default_effort: None,
1791            autocompact: None,
1792            autocompact_pct: None,
1793            spec: ModelSpec::AutoResolve {
1794                provider: provider.to_string(),
1795                match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1796                exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
1797            },
1798        }
1799    }
1800
1801    fn pinned_match_alias(
1802        model: &str,
1803        provider: &str,
1804        match_patterns: &[&str],
1805        exclude_patterns: &[&str],
1806    ) -> ModelAlias {
1807        ModelAlias {
1808            harness: None,
1809            description: None,
1810            default_effort: None,
1811            autocompact: None,
1812            autocompact_pct: None,
1813            spec: ModelSpec::PinnedWithMatch {
1814                model: model.to_string(),
1815                provider: Some(provider.to_string()),
1816                match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1817                exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
1818            },
1819        }
1820    }
1821
1822    #[test]
1823    fn resolve_with_alias_prefix_basic() {
1824        let aliases = builtin_aliases();
1825        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
1826
1827        let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
1828        assert_eq!(resolved.name, "opus-4-6");
1829        assert_eq!(resolved.model_id, "claude-opus-4-6");
1830        assert_eq!(resolved.provider, "anthropic");
1831        assert_eq!(
1832            resolved.harness_candidates,
1833            vec!["claude", "opencode", "gemini"]
1834        );
1835
1836        let installed = harness::detect_installed_harnesses();
1837        let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1838        let expected_source = if expected_harness.is_some() {
1839            HarnessSource::AutoDetected
1840        } else {
1841            HarnessSource::Unavailable
1842        };
1843        assert_eq!(resolved.harness, expected_harness);
1844        assert_eq!(resolved.harness_source, expected_source);
1845    }
1846
1847    #[test]
1848    fn resolve_with_alias_prefix_no_candidates() {
1849        let aliases = builtin_aliases();
1850        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
1851
1852        let resolved = resolve_with_alias_prefix("opus-9-9", &aliases, &cache);
1853        assert!(resolved.is_none());
1854    }
1855
1856    #[test]
1857    fn resolve_with_alias_prefix_picks_newest() {
1858        let aliases = builtin_aliases();
1859        let cache = make_cache(vec![
1860            ("claude-opus-4-6-20250101", "Anthropic", Some("2025-01-01")),
1861            ("claude-opus-4-6-20260101", "Anthropic", Some("2026-01-01")),
1862        ]);
1863
1864        let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
1865        assert_eq!(resolved.model_id, "claude-opus-4-6-20260101");
1866    }
1867
1868    #[test]
1869    fn resolve_with_alias_prefix_lexical_id_tiebreaker_when_date_and_length_equal() {
1870        let aliases = builtin_aliases();
1871        let cache = make_cache(vec![
1872            ("claude-opus-4-b", "Anthropic", Some("2026-02-05")),
1873            ("claude-opus-4-a", "Anthropic", Some("2026-02-05")),
1874        ]);
1875
1876        let resolved = resolve_with_alias_prefix("opus-4-", &aliases, &cache).unwrap();
1877        assert_eq!(resolved.model_id, "claude-opus-4-a");
1878    }
1879
1880    #[test]
1881    fn resolve_with_alias_prefix_pinned_base_inherits_defaults() {
1882        let mut aliases = IndexMap::new();
1883        let mut alias = pinned_alias(Some("claude"), "claude-opus-4-6");
1884        alias.default_effort = Some("high".to_string());
1885        alias.autocompact = Some(42);
1886        aliases.insert("opus".to_string(), alias);
1887        let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
1888
1889        let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
1890        assert_eq!(resolved.model_id, "claude-opus-4-7");
1891        assert_eq!(resolved.default_effort.as_deref(), Some("high"));
1892        assert_eq!(resolved.autocompact, Some(42));
1893    }
1894
1895    #[test]
1896    fn resolve_with_alias_prefix_auto_base_does_not_inherit_defaults() {
1897        let mut aliases = IndexMap::new();
1898        let mut alias = auto_alias("anthropic", &["claude-opus-*"], &[]);
1899        alias.default_effort = Some("high".to_string());
1900        alias.autocompact = Some(42);
1901        aliases.insert("opus".to_string(), alias);
1902        let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
1903
1904        let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
1905        assert_eq!(resolved.model_id, "claude-opus-4-7");
1906        assert_eq!(resolved.default_effort, None);
1907        assert_eq!(resolved.autocompact, None);
1908    }
1909
1910    #[test]
1911    fn resolve_with_alias_prefix_exact_name_matches() {
1912        // When the input equals an alias name, this function still finds matches
1913        // via glob *opus*. The caller (run_resolve) handles exact alias lookup
1914        // before calling this function, so this path is only reached for
1915        // non-alias inputs in practice.
1916        let aliases = builtin_aliases();
1917        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
1918
1919        let resolved = resolve_with_alias_prefix("opus", &aliases, &cache);
1920        assert!(resolved.is_some());
1921        assert_eq!(resolved.unwrap().model_id, "claude-opus-4-6");
1922    }
1923
1924    #[test]
1925    fn resolve_with_alias_prefix_multiple_aliases_union() {
1926        let mut aliases = IndexMap::new();
1927        aliases.insert(
1928            "g".to_string(),
1929            auto_alias("openai", &["gpt-2026-08*"], &[]),
1930        );
1931        aliases.insert(
1932            "gpt".to_string(),
1933            auto_alias("openai", &["gpt-2026-03*"], &[]),
1934        );
1935        let cache = make_cache(vec![
1936            ("gpt-2026-03-01", "OpenAI", Some("2026-03-01")),
1937            ("gpt-2026-08-07", "OpenAI", Some("2026-08-07")),
1938        ]);
1939
1940        let resolved = resolve_with_alias_prefix("gpt-2026", &aliases, &cache).unwrap();
1941        assert_eq!(resolved.model_id, "gpt-2026-08-07");
1942    }
1943
1944    #[test]
1945    fn merge_empty_returns_builtins() {
1946        let mut diag = DiagnosticCollector::new();
1947        let merged = merge_model_config(&IndexMap::new(), &[], &mut diag, None);
1948        // Empty consumer + no deps = builtins only
1949        assert!(merged.contains_key("opus"));
1950        assert!(merged.contains_key("sonnet"));
1951        assert!(merged.contains_key("codex"));
1952    }
1953
1954    #[test]
1955    fn merge_consumer_overrides_dependency_alias() {
1956        let mut consumer = IndexMap::new();
1957        consumer.insert(
1958            "opus".to_string(),
1959            pinned_alias(Some("custom"), "my-opus-model"),
1960        );
1961
1962        let mut diag = DiagnosticCollector::new();
1963        let merged = merge_model_config(&consumer, &[], &mut diag, None);
1964        assert_eq!(
1965            merged.get("opus").unwrap().spec,
1966            ModelSpec::Pinned {
1967                model: "my-opus-model".to_string(),
1968                provider: None
1969            }
1970        );
1971    }
1972
1973    #[test]
1974    fn merge_dep_overrides_builtin() {
1975        let dep = ResolvedDepModels {
1976            source_name: "my-pkg".to_string(),
1977            models: {
1978                let mut m = IndexMap::new();
1979                m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
1980                m
1981            },
1982        };
1983
1984        let mut diag = DiagnosticCollector::new();
1985        let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag, None);
1986        // Dep overrides builtin
1987        assert_eq!(
1988            merged.get("opus").unwrap().spec,
1989            ModelSpec::Pinned {
1990                model: "pkg-opus".to_string(),
1991                provider: None
1992            }
1993        );
1994    }
1995
1996    #[test]
1997    fn merge_consumer_beats_dep() {
1998        let mut consumer = IndexMap::new();
1999        consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
2000
2001        let dep = ResolvedDepModels {
2002            source_name: "pkg".to_string(),
2003            models: {
2004                let mut m = IndexMap::new();
2005                m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
2006                m
2007            },
2008        };
2009
2010        let mut diag = DiagnosticCollector::new();
2011        let merged = merge_model_config(&consumer, &[dep], &mut diag, None);
2012        assert_eq!(
2013            merged.get("opus").unwrap().spec,
2014            ModelSpec::Pinned {
2015                model: "consumer-opus".to_string(),
2016                provider: None
2017            }
2018        );
2019    }
2020
2021    #[test]
2022    fn merge_dep_conflict_warns_with_winner_and_resolution_hint() {
2023        let dep1 = ResolvedDepModels {
2024            source_name: "pkg-a".to_string(),
2025            models: {
2026                let mut m = IndexMap::new();
2027                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2028                m
2029            },
2030        };
2031        let dep2 = ResolvedDepModels {
2032            source_name: "pkg-b".to_string(),
2033            models: {
2034                let mut m = IndexMap::new();
2035                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2036                m
2037            },
2038        };
2039
2040        let mut diag = DiagnosticCollector::new();
2041        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2042        // First dep wins
2043        assert_eq!(
2044            merged.get("custom").unwrap().spec,
2045            ModelSpec::Pinned {
2046                model: "model-a".to_string(),
2047                provider: None
2048            }
2049        );
2050        // Should have warned
2051        let warnings = diag.drain();
2052        assert_eq!(warnings.len(), 1);
2053        assert_eq!(warnings[0].code, "model-alias-conflict");
2054        assert_eq!(
2055            warnings[0].message,
2056            "model alias `custom` defined by both `pkg-a` and `pkg-b` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2057        );
2058    }
2059
2060    #[test]
2061    fn merge_dep_conflict_with_cache_shows_resolution_diff() {
2062        let cache = make_cache(vec![
2063            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2064            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2065        ]);
2066        let dep1 = ResolvedDepModels {
2067            source_name: "dep-a".to_string(),
2068            models: {
2069                let mut m = IndexMap::new();
2070                m.insert(
2071                    "opus".to_string(),
2072                    pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2073                );
2074                m
2075            },
2076        };
2077        let dep2 = ResolvedDepModels {
2078            source_name: "dep-b".to_string(),
2079            models: {
2080                let mut m = IndexMap::new();
2081                m.insert(
2082                    "opus".to_string(),
2083                    pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2084                );
2085                m
2086            },
2087        };
2088
2089        let mut diag = DiagnosticCollector::new();
2090        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2091        let warnings = diag.drain();
2092        assert_eq!(warnings.len(), 1);
2093        let message = &warnings[0].message;
2094        assert!(message.contains("dep-a → claude-opus-4-6 (pinned+match)"));
2095        assert!(message.contains("dep-b → claude-opus-4-7 (pinned+match)"));
2096    }
2097
2098    #[test]
2099    fn merge_dep_conflict_with_cache_same_resolution() {
2100        let cache = make_cache(vec![
2101            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2102            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2103        ]);
2104        let dep1 = ResolvedDepModels {
2105            source_name: "dep-a".to_string(),
2106            models: {
2107                let mut m = IndexMap::new();
2108                m.insert(
2109                    "opus".to_string(),
2110                    pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2111                );
2112                m
2113            },
2114        };
2115        let dep2 = ResolvedDepModels {
2116            source_name: "dep-b".to_string(),
2117            models: {
2118                let mut m = IndexMap::new();
2119                m.insert(
2120                    "opus".to_string(),
2121                    auto_alias("Anthropic", &["claude-opus-*"], &[]),
2122                );
2123                m
2124            },
2125        };
2126
2127        let mut diag = DiagnosticCollector::new();
2128        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2129        let warnings = diag.drain();
2130        assert_eq!(warnings.len(), 1);
2131        assert!(
2132            warnings[0]
2133                .message
2134                .contains("both resolve to claude-opus-4-7")
2135        );
2136    }
2137
2138    #[test]
2139    fn merge_dep_conflict_without_cache_uses_old_format() {
2140        let dep1 = ResolvedDepModels {
2141            source_name: "dep-a".to_string(),
2142            models: {
2143                let mut m = IndexMap::new();
2144                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2145                m
2146            },
2147        };
2148        let dep2 = ResolvedDepModels {
2149            source_name: "dep-b".to_string(),
2150            models: {
2151                let mut m = IndexMap::new();
2152                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2153                m
2154            },
2155        };
2156
2157        let mut diag = DiagnosticCollector::new();
2158        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2159        let warnings = diag.drain();
2160        assert_eq!(warnings.len(), 1);
2161        assert_eq!(
2162            warnings[0].message,
2163            "model alias `custom` defined by both `dep-a` and `dep-b` — using dep-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2164        );
2165    }
2166
2167    #[test]
2168    fn merge_dep_three_way_conflict_warns_each_loser_against_first_winner() {
2169        let dep1 = ResolvedDepModels {
2170            source_name: "pkg-a".to_string(),
2171            models: {
2172                let mut m = IndexMap::new();
2173                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2174                m
2175            },
2176        };
2177        let dep2 = ResolvedDepModels {
2178            source_name: "pkg-b".to_string(),
2179            models: {
2180                let mut m = IndexMap::new();
2181                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2182                m
2183            },
2184        };
2185        let dep3 = ResolvedDepModels {
2186            source_name: "pkg-c".to_string(),
2187            models: {
2188                let mut m = IndexMap::new();
2189                m.insert("custom".to_string(), pinned_alias(Some("c"), "model-c"));
2190                m
2191            },
2192        };
2193
2194        let mut diag = DiagnosticCollector::new();
2195        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2, dep3], &mut diag, None);
2196
2197        assert_eq!(
2198            merged.get("custom").unwrap().spec,
2199            ModelSpec::Pinned {
2200                model: "model-a".to_string(),
2201                provider: None
2202            }
2203        );
2204
2205        let warnings = diag.drain();
2206        assert_eq!(warnings.len(), 2);
2207        assert_eq!(
2208            warnings[0].message,
2209            "model alias `custom` defined by both `pkg-a` and `pkg-b` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2210        );
2211        assert_eq!(
2212            warnings[1].message,
2213            "model alias `custom` defined by both `pkg-a` and `pkg-c` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2214        );
2215    }
2216
2217    #[test]
2218    fn merge_consumer_override_suppresses_dep_conflict_warning() {
2219        let mut consumer = IndexMap::new();
2220        consumer.insert(
2221            "custom".to_string(),
2222            pinned_alias(Some("consumer"), "consumer-model"),
2223        );
2224
2225        let dep1 = ResolvedDepModels {
2226            source_name: "pkg-a".to_string(),
2227            models: {
2228                let mut m = IndexMap::new();
2229                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2230                m
2231            },
2232        };
2233        let dep2 = ResolvedDepModels {
2234            source_name: "pkg-b".to_string(),
2235            models: {
2236                let mut m = IndexMap::new();
2237                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2238                m
2239            },
2240        };
2241
2242        let mut diag = DiagnosticCollector::new();
2243        let merged = merge_model_config(&consumer, &[dep1, dep2], &mut diag, None);
2244
2245        assert_eq!(
2246            merged.get("custom").unwrap().spec,
2247            ModelSpec::Pinned {
2248                model: "consumer-model".to_string(),
2249                provider: None
2250            }
2251        );
2252        assert!(diag.drain().is_empty());
2253    }
2254
2255    #[test]
2256    fn merge_dep_conflicts_are_non_blocking() {
2257        let dep1 = ResolvedDepModels {
2258            source_name: "pkg-a".to_string(),
2259            models: {
2260                let mut m = IndexMap::new();
2261                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2262                m
2263            },
2264        };
2265        let dep2 = ResolvedDepModels {
2266            source_name: "pkg-b".to_string(),
2267            models: {
2268                let mut m = IndexMap::new();
2269                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2270                m.insert("extra".to_string(), pinned_alias(Some("b"), "model-extra"));
2271                m
2272            },
2273        };
2274
2275        let mut diag = DiagnosticCollector::new();
2276        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2277
2278        assert!(merged.contains_key("opus"));
2279        assert_eq!(
2280            merged.get("custom").unwrap().spec,
2281            ModelSpec::Pinned {
2282                model: "model-a".to_string(),
2283                provider: None
2284            }
2285        );
2286        assert_eq!(
2287            merged.get("extra").unwrap().spec,
2288            ModelSpec::Pinned {
2289                model: "model-extra".to_string(),
2290                provider: None
2291            }
2292        );
2293        assert_eq!(diag.drain().len(), 1);
2294    }
2295
2296    // -- resolve_all tests --
2297
2298    #[test]
2299    fn resolve_all_pinned() {
2300        let mut aliases = IndexMap::new();
2301        aliases.insert(
2302            "fast".to_string(),
2303            pinned_alias(Some("claude"), "claude-haiku-4-5"),
2304        );
2305
2306        let cache = ModelsCache {
2307            models: Vec::new(),
2308            fetched_at: None,
2309        };
2310
2311        let mut diag = DiagnosticCollector::new();
2312        let resolved = resolve_all(&aliases, &cache, &mut diag);
2313        let entry = resolved.get("fast").unwrap();
2314        assert_eq!(entry.model_id, "claude-haiku-4-5");
2315        assert_eq!(entry.provider, "anthropic");
2316    }
2317
2318    #[test]
2319    fn resolve_all_copies_alias_defaults() {
2320        let mut aliases = IndexMap::new();
2321        let mut alias = pinned_alias(Some("claude"), "claude-haiku-4-5");
2322        alias.default_effort = Some("medium".to_string());
2323        alias.autocompact = Some(30);
2324        aliases.insert("fast".to_string(), alias);
2325
2326        let cache = ModelsCache {
2327            models: Vec::new(),
2328            fetched_at: None,
2329        };
2330
2331        let mut diag = DiagnosticCollector::new();
2332        let resolved = resolve_all(&aliases, &cache, &mut diag);
2333        let entry = resolved.get("fast").unwrap();
2334        assert_eq!(entry.default_effort.as_deref(), Some("medium"));
2335        assert_eq!(entry.autocompact, Some(30));
2336    }
2337
2338    #[test]
2339    fn resolve_all_pinned_with_provider() {
2340        let mut aliases = IndexMap::new();
2341        aliases.insert(
2342            "fast".to_string(),
2343            ModelAlias {
2344                harness: None,
2345                description: None,
2346                default_effort: None,
2347                autocompact: None,
2348                autocompact_pct: None,
2349                spec: ModelSpec::Pinned {
2350                    model: "gpt-5.3-codex".to_string(),
2351                    provider: Some("openai".to_string()),
2352                },
2353            },
2354        );
2355
2356        let cache = ModelsCache {
2357            models: Vec::new(),
2358            fetched_at: None,
2359        };
2360
2361        let mut diag = DiagnosticCollector::new();
2362        let resolved = resolve_all(&aliases, &cache, &mut diag);
2363        let entry = resolved.get("fast").unwrap();
2364        assert_eq!(entry.model_id, "gpt-5.3-codex");
2365        assert_eq!(entry.provider, "openai");
2366        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
2367    }
2368
2369    #[test]
2370    fn resolve_all_pinned_auto_detect_harness() {
2371        let mut aliases = IndexMap::new();
2372        aliases.insert(
2373            "opus".to_string(),
2374            ModelAlias {
2375                harness: None,
2376                description: None,
2377                default_effort: None,
2378                autocompact: None,
2379                autocompact_pct: None,
2380                spec: ModelSpec::Pinned {
2381                    model: "claude-opus-4-6".to_string(),
2382                    provider: Some("anthropic".to_string()),
2383                },
2384            },
2385        );
2386
2387        let cache = ModelsCache {
2388            models: Vec::new(),
2389            fetched_at: None,
2390        };
2391
2392        let mut diag = DiagnosticCollector::new();
2393        let resolved = resolve_all(&aliases, &cache, &mut diag);
2394        let entry = resolved.get("opus").unwrap();
2395        assert_eq!(entry.model_id, "claude-opus-4-6");
2396        assert_eq!(entry.provider, "anthropic");
2397
2398        let installed = harness::detect_installed_harnesses();
2399        let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
2400        let expected_source = if expected_harness.is_some() {
2401            HarnessSource::AutoDetected
2402        } else {
2403            HarnessSource::Unavailable
2404        };
2405
2406        assert_eq!(entry.harness, expected_harness);
2407        assert_eq!(entry.harness_source, expected_source);
2408    }
2409
2410    #[test]
2411    fn resolve_all_auto_detect_harness() {
2412        let mut aliases = IndexMap::new();
2413        aliases.insert(
2414            "gpt".to_string(),
2415            ModelAlias {
2416                harness: None,
2417                description: None,
2418                default_effort: None,
2419                autocompact: None,
2420                autocompact_pct: None,
2421                spec: ModelSpec::AutoResolve {
2422                    provider: "openai".to_string(),
2423                    match_patterns: vec!["gpt-5*".to_string()],
2424                    exclude_patterns: vec![],
2425                },
2426            },
2427        );
2428        let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
2429
2430        let mut diag = DiagnosticCollector::new();
2431        let resolved = resolve_all(&aliases, &cache, &mut diag);
2432        let entry = resolved.get("gpt").unwrap();
2433        assert_eq!(entry.model_id, "gpt-5");
2434        assert_eq!(entry.provider, "openai");
2435        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
2436        match entry.harness_source {
2437            HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
2438            HarnessSource::Unavailable => assert!(entry.harness.is_none()),
2439            HarnessSource::Explicit => panic!("unexpected explicit harness source"),
2440        }
2441    }
2442
2443    #[test]
2444    fn resolve_all_unavailable_harness_still_included() {
2445        let mut aliases = IndexMap::new();
2446        aliases.insert(
2447            "opus".to_string(),
2448            ModelAlias {
2449                harness: Some("missing-harness-xyz".to_string()),
2450                description: None,
2451                default_effort: None,
2452                autocompact: None,
2453                autocompact_pct: None,
2454                spec: ModelSpec::Pinned {
2455                    model: "claude-opus-4-6".to_string(),
2456                    provider: None,
2457                },
2458            },
2459        );
2460
2461        let cache = ModelsCache {
2462            models: Vec::new(),
2463            fetched_at: None,
2464        };
2465
2466        let mut diag = DiagnosticCollector::new();
2467        let resolved = resolve_all(&aliases, &cache, &mut diag);
2468        let entry = resolved.get("opus").unwrap();
2469        assert_eq!(entry.model_id, "claude-opus-4-6");
2470        assert_eq!(entry.provider, "anthropic");
2471        assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
2472        assert_eq!(entry.harness_source, HarnessSource::Unavailable);
2473    }
2474
2475    #[test]
2476    fn resolve_all_empty_cache_omits_unresolvable() {
2477        let mut aliases = IndexMap::new();
2478        aliases.insert(
2479            "opus".to_string(),
2480            ModelAlias {
2481                harness: Some("claude".to_string()),
2482                description: None,
2483                default_effort: None,
2484                autocompact: None,
2485                autocompact_pct: None,
2486                spec: ModelSpec::AutoResolve {
2487                    provider: "Anthropic".to_string(),
2488                    match_patterns: vec!["claude-opus-*".to_string()],
2489                    exclude_patterns: vec![],
2490                },
2491            },
2492        );
2493        let cache = ModelsCache {
2494            models: Vec::new(),
2495            fetched_at: None,
2496        };
2497
2498        let mut diag = DiagnosticCollector::new();
2499        let resolved = resolve_all(&aliases, &cache, &mut diag);
2500        // No cache → auto-resolve can't match → alias omitted from results
2501        assert!(!resolved.contains_key("opus"));
2502    }
2503
2504    #[test]
2505    fn resolve_all_pinned_with_match_uses_model_field() {
2506        let mut aliases = IndexMap::new();
2507        aliases.insert(
2508            "opus".to_string(),
2509            pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2510        );
2511        let cache = make_cache(vec![
2512            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2513            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2514        ]);
2515
2516        let mut diag = DiagnosticCollector::new();
2517        let resolved = resolve_all(&aliases, &cache, &mut diag);
2518        assert_eq!(resolved.get("opus").unwrap().model_id, "claude-opus-4-6");
2519        assert!(diag.drain().is_empty());
2520    }
2521
2522    #[test]
2523    fn resolve_one_scopes_diagnostics_to_requested_alias() {
2524        let mut aliases = IndexMap::new();
2525        aliases.insert(
2526            "opus".to_string(),
2527            pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2528        );
2529        aliases.insert(
2530            "sonnet".to_string(),
2531            pinned_match_alias("claude-sonnet-4-5", "Anthropic", &["claude-sonnet-*"], &[]),
2532        );
2533        let cache = make_cache(vec![
2534            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2535            ("claude-sonnet-4-7", "Anthropic", Some("2026-04-16")),
2536        ]);
2537
2538        let mut diag = DiagnosticCollector::new();
2539        let resolved = resolve_one("opus", &aliases, &cache, &mut diag).unwrap();
2540        assert_eq!(resolved.name, "opus");
2541        assert!(diag.drain().is_empty());
2542    }
2543
2544    fn make_resolved_alias(name: &str) -> ResolvedAlias {
2545        ResolvedAlias {
2546            name: name.to_string(),
2547            model_id: format!("model-{name}"),
2548            provider: "openai".to_string(),
2549            harness: Some("codex".to_string()),
2550            harness_source: HarnessSource::Explicit,
2551            harness_candidates: vec!["codex".to_string()],
2552            description: None,
2553            default_effort: None,
2554            autocompact: None,
2555            autocompact_pct: None,
2556            availability: None,
2557        }
2558    }
2559
2560    #[test]
2561    fn filter_by_visibility_include_mode_keeps_matches_only() {
2562        let mut aliases = IndexMap::new();
2563        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2564        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2565        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2566
2567        let filtered = filter_by_visibility(
2568            aliases,
2569            &crate::config::ModelVisibility {
2570                include: Some(vec!["model-opus*".to_string(), "model-gpt-*".to_string()]),
2571                exclude: None,
2572            },
2573        );
2574
2575        assert_eq!(filtered.len(), 2);
2576        assert!(filtered.contains_key("opus"));
2577        assert!(filtered.contains_key("gpt-5"));
2578        assert!(!filtered.contains_key("sonnet"));
2579    }
2580
2581    #[test]
2582    fn filter_by_visibility_exclude_mode_removes_matches() {
2583        let mut aliases = IndexMap::new();
2584        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2585        aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
2586        aliases.insert(
2587            "deprecated-gpt".to_string(),
2588            make_resolved_alias("deprecated-gpt"),
2589        );
2590
2591        let filtered = filter_by_visibility(
2592            aliases,
2593            &crate::config::ModelVisibility {
2594                include: None,
2595                exclude: Some(vec![
2596                    "model-test-*".to_string(),
2597                    "model-deprecated-*".to_string(),
2598                ]),
2599            },
2600        );
2601
2602        assert_eq!(filtered.len(), 1);
2603        assert!(filtered.contains_key("opus"));
2604        assert!(!filtered.contains_key("test-opus"));
2605        assert!(!filtered.contains_key("deprecated-gpt"));
2606    }
2607
2608    #[test]
2609    fn filter_by_visibility_empty_config_returns_all() {
2610        let mut aliases = IndexMap::new();
2611        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2612        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2613        let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
2614        assert_eq!(filtered.len(), 2);
2615        assert!(filtered.contains_key("opus"));
2616        assert!(filtered.contains_key("sonnet"));
2617    }
2618
2619    #[test]
2620    fn filter_by_visibility_empty_lists_return_all() {
2621        let mut aliases = IndexMap::new();
2622        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2623        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2624        let filtered = filter_by_visibility(
2625            aliases,
2626            &crate::config::ModelVisibility {
2627                include: Some(Vec::new()),
2628                exclude: Some(Vec::new()),
2629            },
2630        );
2631        assert_eq!(filtered.len(), 2);
2632        assert!(filtered.contains_key("opus"));
2633        assert!(filtered.contains_key("sonnet"));
2634    }
2635
2636    #[test]
2637    fn visibility_pattern_matches_bare_provider_and_opencode_slug_forms() {
2638        let paths = vec![availability::RunnablePath {
2639            harness: "opencode".to_string(),
2640            mars_provider: "Anthropic".to_string(),
2641            harness_model_id: "openrouter/anthropic/claude-opus-4.7".to_string(),
2642        }];
2643
2644        assert!(matches_visibility_pattern(
2645            "claude-opus-*",
2646            "claude-opus-4-7",
2647            "Anthropic",
2648            &paths
2649        ));
2650        assert!(matches_visibility_pattern(
2651            "anthropic/claude-opus-*",
2652            "claude-opus-4-7",
2653            "Anthropic",
2654            &paths
2655        ));
2656        assert!(matches_visibility_pattern(
2657            "openrouter/anthropic/*",
2658            "claude-opus-4-7",
2659            "Anthropic",
2660            &paths
2661        ));
2662        assert!(!matches_visibility_pattern(
2663            "anthropic/*/opus",
2664            "claude-opus-4-7",
2665            "Anthropic",
2666            &paths
2667        ));
2668    }
2669
2670    #[test]
2671    fn filter_by_visibility_applies_include_then_exclude() {
2672        let mut aliases = IndexMap::new();
2673        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2674        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2675        aliases.insert("gpt-4".to_string(), make_resolved_alias("gpt-4"));
2676
2677        let filtered = filter_by_visibility(
2678            aliases,
2679            &crate::config::ModelVisibility {
2680                include: Some(vec!["openai/model-*".to_string()]),
2681                exclude: Some(vec!["model-gpt-4".to_string()]),
2682            },
2683        );
2684
2685        assert_eq!(filtered.len(), 2);
2686        assert!(filtered.contains_key("opus"));
2687        assert!(filtered.contains_key("gpt-5"));
2688        assert!(!filtered.contains_key("gpt-4"));
2689    }
2690
2691    #[test]
2692    fn resolve_model_and_provider_pinned_explicit_provider() {
2693        let alias = ModelAlias {
2694            harness: None,
2695            description: None,
2696            default_effort: None,
2697            autocompact: None,
2698            autocompact_pct: None,
2699            spec: ModelSpec::Pinned {
2700                model: "claude-opus-4-6".to_string(),
2701                provider: Some("anthropic".to_string()),
2702            },
2703        };
2704        let cache = ModelsCache {
2705            models: Vec::new(),
2706            fetched_at: None,
2707        };
2708
2709        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2710        assert_eq!(
2711            resolved,
2712            ("claude-opus-4-6".to_string(), "anthropic".to_string())
2713        );
2714    }
2715
2716    #[test]
2717    fn resolve_model_and_provider_pinned_inferred() {
2718        let alias = ModelAlias {
2719            harness: None,
2720            description: None,
2721            default_effort: None,
2722            autocompact: None,
2723            autocompact_pct: None,
2724            spec: ModelSpec::Pinned {
2725                model: "claude-opus-4-6".to_string(),
2726                provider: None,
2727            },
2728        };
2729        let cache = ModelsCache {
2730            models: Vec::new(),
2731            fetched_at: None,
2732        };
2733
2734        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2735        assert_eq!(
2736            resolved,
2737            ("claude-opus-4-6".to_string(), "anthropic".to_string())
2738        );
2739    }
2740
2741    #[test]
2742    fn resolve_model_and_provider_pinned_unknown() {
2743        let alias = ModelAlias {
2744            harness: None,
2745            description: None,
2746            default_effort: None,
2747            autocompact: None,
2748            autocompact_pct: None,
2749            spec: ModelSpec::Pinned {
2750                model: "my-custom-model".to_string(),
2751                provider: None,
2752            },
2753        };
2754        let cache = ModelsCache {
2755            models: Vec::new(),
2756            fetched_at: None,
2757        };
2758
2759        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2760        assert_eq!(
2761            resolved,
2762            ("my-custom-model".to_string(), "unknown".to_string())
2763        );
2764    }
2765
2766    #[test]
2767    fn resolve_model_and_provider_auto_resolve() {
2768        let alias = ModelAlias {
2769            harness: None,
2770            description: None,
2771            default_effort: None,
2772            autocompact: None,
2773            autocompact_pct: None,
2774            spec: ModelSpec::AutoResolve {
2775                provider: "openai".to_string(),
2776                match_patterns: vec!["gpt-5*".to_string()],
2777                exclude_patterns: vec![],
2778            },
2779        };
2780        let cache = make_cache(vec![
2781            ("gpt-4o", "OpenAI", Some("2024-06-01")),
2782            ("gpt-5", "OpenAI", Some("2025-06-01")),
2783        ]);
2784
2785        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2786        assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
2787    }
2788
2789    #[test]
2790    fn resolve_harness_explicit_installed() {
2791        let alias = ModelAlias {
2792            harness: Some("claude".to_string()),
2793            description: None,
2794            default_effort: None,
2795            autocompact: None,
2796            autocompact_pct: None,
2797            spec: ModelSpec::Pinned {
2798                model: "claude-opus-4-6".to_string(),
2799                provider: None,
2800            },
2801        };
2802        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
2803
2804        let resolved = resolve_harness(&alias, "anthropic", &installed);
2805        assert_eq!(
2806            resolved,
2807            (Some("claude".to_string()), HarnessSource::Explicit)
2808        );
2809    }
2810
2811    #[test]
2812    fn resolve_harness_explicit_not_installed() {
2813        let alias = ModelAlias {
2814            harness: Some("claude".to_string()),
2815            description: None,
2816            default_effort: None,
2817            autocompact: None,
2818            autocompact_pct: None,
2819            spec: ModelSpec::Pinned {
2820                model: "claude-opus-4-6".to_string(),
2821                provider: None,
2822            },
2823        };
2824        let installed = HashSet::new();
2825
2826        let resolved = resolve_harness(&alias, "anthropic", &installed);
2827        assert_eq!(
2828            resolved,
2829            (Some("claude".to_string()), HarnessSource::Unavailable)
2830        );
2831    }
2832
2833    #[test]
2834    fn resolve_harness_auto_detected() {
2835        let alias = ModelAlias {
2836            harness: None,
2837            description: None,
2838            default_effort: None,
2839            autocompact: None,
2840            autocompact_pct: None,
2841            spec: ModelSpec::Pinned {
2842                model: "claude-opus-4-6".to_string(),
2843                provider: Some("anthropic".to_string()),
2844            },
2845        };
2846        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
2847
2848        let resolved = resolve_harness(&alias, "anthropic", &installed);
2849        assert_eq!(
2850            resolved,
2851            (Some("claude".to_string()), HarnessSource::AutoDetected)
2852        );
2853    }
2854
2855    #[test]
2856    fn resolve_harness_unavailable() {
2857        let alias = ModelAlias {
2858            harness: None,
2859            description: None,
2860            default_effort: None,
2861            autocompact: None,
2862            autocompact_pct: None,
2863            spec: ModelSpec::Pinned {
2864                model: "claude-opus-4-6".to_string(),
2865                provider: Some("anthropic".to_string()),
2866            },
2867        };
2868        let installed = HashSet::new();
2869
2870        let resolved = resolve_harness(&alias, "anthropic", &installed);
2871        assert_eq!(resolved, (None, HarnessSource::Unavailable));
2872    }
2873
2874    #[test]
2875    fn resolve_harness_unavailable_no_provider_match() {
2876        let alias = ModelAlias {
2877            harness: None,
2878            description: None,
2879            default_effort: None,
2880            autocompact: None,
2881            autocompact_pct: None,
2882            spec: ModelSpec::Pinned {
2883                model: "my-custom-model".to_string(),
2884                provider: Some("unknown".to_string()),
2885            },
2886        };
2887        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
2888
2889        let resolved = resolve_harness(&alias, "unknown", &installed);
2890        assert_eq!(resolved, (None, HarnessSource::Unavailable));
2891    }
2892
2893    // -- serde roundtrip tests --
2894
2895    #[test]
2896    fn harness_source_serializes_snake_case() {
2897        assert_eq!(
2898            serde_json::to_string(&HarnessSource::Explicit).unwrap(),
2899            "\"explicit\""
2900        );
2901        assert_eq!(
2902            serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
2903            "\"auto_detected\""
2904        );
2905        assert_eq!(
2906            serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
2907            "\"unavailable\""
2908        );
2909    }
2910
2911    #[test]
2912    fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
2913        let toml_str = r#"
2914[models.fast]
2915harness = "claude"
2916model = "claude-haiku-4-5"
2917description = "Fast and cheap"
2918"#;
2919
2920        #[derive(Debug, Deserialize)]
2921        struct Wrapper {
2922            #[allow(dead_code)]
2923            models: IndexMap<String, ModelAlias>,
2924        }
2925
2926        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
2927        let alias = parsed.models.get("fast").unwrap();
2928        assert_eq!(
2929            alias.spec,
2930            ModelSpec::Pinned {
2931                model: "claude-haiku-4-5".to_string(),
2932                provider: None
2933            }
2934        );
2935        assert_eq!(alias.harness.as_deref(), Some("claude"));
2936        assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
2937
2938        let json = serde_json::to_string(alias).unwrap();
2939        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
2940        assert_eq!(roundtripped, *alias);
2941    }
2942
2943    #[test]
2944    fn model_alias_pinned_toml_roundtrip_without_harness() {
2945        let toml_str = r#"
2946[models.fast]
2947model = "claude-haiku-4-5"
2948"#;
2949
2950        #[derive(Debug, Deserialize)]
2951        struct Wrapper {
2952            #[allow(dead_code)]
2953            models: IndexMap<String, ModelAlias>,
2954        }
2955
2956        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
2957        let alias = parsed.models.get("fast").unwrap();
2958        assert_eq!(alias.harness, None);
2959        assert_eq!(
2960            alias.spec,
2961            ModelSpec::Pinned {
2962                model: "claude-haiku-4-5".to_string(),
2963                provider: None
2964            }
2965        );
2966
2967        let json = serde_json::to_string(alias).unwrap();
2968        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
2969        assert!(value.get("harness").is_none());
2970        assert!(value.get("provider").is_none());
2971        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
2972        assert_eq!(roundtripped, *alias);
2973    }
2974
2975    #[test]
2976    fn model_alias_pinned_toml_roundtrip_with_provider() {
2977        let toml_str = r#"
2978[models.fast]
2979model = "claude-haiku-4-5"
2980provider = "anthropic"
2981"#;
2982
2983        #[derive(Debug, Deserialize)]
2984        struct Wrapper {
2985            #[allow(dead_code)]
2986            models: IndexMap<String, ModelAlias>,
2987        }
2988
2989        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
2990        let alias = parsed.models.get("fast").unwrap();
2991        assert_eq!(alias.harness, None);
2992        assert_eq!(
2993            alias.spec,
2994            ModelSpec::Pinned {
2995                model: "claude-haiku-4-5".to_string(),
2996                provider: Some("anthropic".to_string())
2997            }
2998        );
2999
3000        let json = serde_json::to_string(alias).unwrap();
3001        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3002        assert_eq!(
3003            value.get("provider").and_then(serde_json::Value::as_str),
3004            Some("anthropic")
3005        );
3006        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3007        assert_eq!(roundtripped, *alias);
3008    }
3009
3010    #[test]
3011    fn model_alias_pinned_json_roundtrip_with_provider() {
3012        let json = r#"{
3013            "model": "gpt-5.3-codex",
3014            "provider": "openai"
3015        }"#;
3016
3017        let alias: ModelAlias = serde_json::from_str(json).unwrap();
3018        assert_eq!(alias.harness, None);
3019        assert_eq!(alias.description, None);
3020        assert_eq!(
3021            alias.spec,
3022            ModelSpec::Pinned {
3023                model: "gpt-5.3-codex".to_string(),
3024                provider: Some("openai".to_string())
3025            }
3026        );
3027
3028        let encoded = serde_json::to_string(&alias).unwrap();
3029        let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
3030        assert_eq!(roundtripped, alias);
3031    }
3032
3033    #[test]
3034    fn model_alias_auto_resolve_toml_roundtrip() {
3035        let toml_str = r#"
3036[models.opus]
3037harness = "claude"
3038provider = "Anthropic"
3039match = ["claude-opus-*"]
3040exclude = ["claude-opus-3*"]
3041description = "Best reasoning"
3042"#;
3043
3044        #[derive(Debug, Deserialize)]
3045        struct Wrapper {
3046            #[allow(dead_code)]
3047            models: IndexMap<String, ModelAlias>,
3048        }
3049
3050        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3051        let alias = parsed.models.get("opus").unwrap();
3052        assert_eq!(alias.harness.as_deref(), Some("claude"));
3053        match &alias.spec {
3054            ModelSpec::AutoResolve {
3055                provider,
3056                match_patterns,
3057                exclude_patterns,
3058            } => {
3059                assert_eq!(provider, "Anthropic");
3060                assert_eq!(match_patterns, &["claude-opus-*"]);
3061                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3062            }
3063            _ => panic!("expected AutoResolve"),
3064        }
3065    }
3066
3067    #[test]
3068    fn model_alias_model_and_match_toml_roundtrip() {
3069        let toml_str = r#"
3070[models.opus]
3071model = "claude-opus-4-6"
3072provider = "anthropic"
3073match = ["claude-opus-*"]
3074exclude = ["claude-opus-3*"]
3075"#;
3076
3077        #[derive(Debug, Deserialize)]
3078        struct Wrapper {
3079            #[allow(dead_code)]
3080            models: IndexMap<String, ModelAlias>,
3081        }
3082
3083        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3084        let alias = parsed.models.get("opus").unwrap();
3085        match &alias.spec {
3086            ModelSpec::PinnedWithMatch {
3087                model,
3088                provider,
3089                match_patterns,
3090                exclude_patterns,
3091            } => {
3092                assert_eq!(model, "claude-opus-4-6");
3093                assert_eq!(provider.as_deref(), Some("anthropic"));
3094                assert_eq!(match_patterns, &["claude-opus-*"]);
3095                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3096            }
3097            _ => panic!("expected PinnedWithMatch"),
3098        }
3099
3100        let json = serde_json::to_string(alias).unwrap();
3101        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3102        assert_eq!(roundtripped, *alias);
3103    }
3104
3105    #[test]
3106    fn model_alias_model_with_exclude_without_match_errors() {
3107        let toml_str = r#"
3108[models.opus]
3109model = "claude-opus-4-7"
3110exclude = ["claude-opus-3*"]
3111"#;
3112
3113        #[derive(Debug, Deserialize)]
3114        struct Wrapper {
3115            #[allow(dead_code)]
3116            models: IndexMap<String, ModelAlias>,
3117        }
3118
3119        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3120        assert!(err.contains("must also include 'match'"));
3121    }
3122
3123    #[test]
3124    fn model_alias_defaults_toml_roundtrip() {
3125        let toml_str = r#"
3126[models.opus]
3127provider = "Anthropic"
3128match = ["claude-opus-*"]
3129default_effort = "high"
3130autocompact = 25
3131"#;
3132
3133        #[derive(Debug, Deserialize)]
3134        struct Wrapper {
3135            models: IndexMap<String, ModelAlias>,
3136        }
3137
3138        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3139        let alias = parsed.models.get("opus").unwrap();
3140        assert_eq!(alias.default_effort.as_deref(), Some("high"));
3141        assert_eq!(alias.autocompact, Some(25));
3142
3143        let json = serde_json::to_string(alias).unwrap();
3144        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3145        assert_eq!(roundtripped, *alias);
3146    }
3147
3148    #[test]
3149    fn model_alias_empty_default_effort_treated_as_none() {
3150        let toml_str = r#"
3151[models.opus]
3152provider = "Anthropic"
3153match = ["claude-opus-*"]
3154default_effort = ""
3155"#;
3156
3157        #[derive(Debug, Deserialize)]
3158        struct Wrapper {
3159            models: IndexMap<String, ModelAlias>,
3160        }
3161
3162        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3163        let alias = parsed.models.get("opus").unwrap();
3164        assert_eq!(alias.default_effort, None);
3165    }
3166
3167    #[test]
3168    fn model_alias_invalid_default_effort_errors() {
3169        let toml_str = r#"
3170[models.opus]
3171provider = "Anthropic"
3172match = ["claude-opus-*"]
3173default_effort = "maximum"
3174"#;
3175
3176        #[derive(Debug, Deserialize)]
3177        struct Wrapper {
3178            #[allow(dead_code)]
3179            models: IndexMap<String, ModelAlias>,
3180        }
3181
3182        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3183        assert!(err.contains("invalid default_effort"));
3184        assert!(err.contains("accepted values"));
3185    }
3186
3187    #[test]
3188    fn model_alias_autocompact_out_of_range_errors() {
3189        // autocompact_pct out of range (>100) is a hard error
3190        let toml_str = r#"
3191[models.opus]
3192provider = "Anthropic"
3193match = ["claude-opus-*"]
3194autocompact_pct = 101
3195"#;
3196
3197        #[derive(Debug, Deserialize)]
3198        struct Wrapper {
3199            #[allow(dead_code)]
3200            models: IndexMap<String, ModelAlias>,
3201        }
3202
3203        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3204        assert!(err.contains("out of range 1-100"));
3205    }
3206
3207    #[test]
3208    fn model_alias_autocompact_boolean_errors() {
3209        let toml_str = r#"
3210[models.opus]
3211provider = "Anthropic"
3212match = ["claude-opus-*"]
3213autocompact = true
3214"#;
3215
3216        #[derive(Debug, Deserialize)]
3217        struct Wrapper {
3218            #[allow(dead_code)]
3219            models: IndexMap<String, ModelAlias>,
3220        }
3221
3222        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3223        assert!(err.contains("autocompact must be an integer (token count)"));
3224    }
3225
3226    #[test]
3227    fn parses_autocompact_pct() {
3228        let toml_str = r#"
3229[models.opus]
3230provider = "Anthropic"
3231match = ["claude-opus-*"]
3232autocompact_pct = 75
3233"#;
3234
3235        #[derive(Debug, Deserialize)]
3236        struct Wrapper {
3237            models: IndexMap<String, ModelAlias>,
3238        }
3239
3240        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3241        let alias = parsed.models.get("opus").unwrap();
3242        assert_eq!(alias.autocompact_pct, Some(75));
3243        assert_eq!(alias.autocompact, None);
3244    }
3245
3246    #[test]
3247    fn autocompact_pct_out_of_range_errors() {
3248        let toml_str = r#"
3249[models.opus]
3250provider = "Anthropic"
3251match = ["claude-opus-*"]
3252autocompact_pct = 150
3253"#;
3254
3255        #[derive(Debug, Deserialize)]
3256        struct Wrapper {
3257            #[allow(dead_code)]
3258            models: IndexMap<String, ModelAlias>,
3259        }
3260
3261        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3262        assert!(err.contains("autocompact_pct"));
3263        assert!(err.contains("out of range 1-100"));
3264    }
3265
3266    #[test]
3267    fn autocompact_pct_zero_errors() {
3268        let toml_str = r#"
3269[models.opus]
3270provider = "Anthropic"
3271match = ["claude-opus-*"]
3272autocompact_pct = 0
3273"#;
3274
3275        #[derive(Debug, Deserialize)]
3276        struct Wrapper {
3277            #[allow(dead_code)]
3278            models: IndexMap<String, ModelAlias>,
3279        }
3280
3281        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3282        assert!(err.contains("autocompact_pct"));
3283        assert!(err.contains("out of range 1-100"));
3284    }
3285
3286    #[test]
3287    fn model_alias_autocompact_zero_accepted() {
3288        let toml_str = r#"
3289[models.opus]
3290model = "claude-opus-4-6"
3291autocompact = 0
3292"#;
3293
3294        #[derive(Debug, Deserialize)]
3295        struct Wrapper {
3296            models: IndexMap<String, ModelAlias>,
3297        }
3298
3299        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3300        let alias = parsed.models.get("opus").unwrap();
3301        assert_eq!(alias.autocompact, Some(0u32));
3302    }
3303
3304    #[test]
3305    fn model_alias_autocompact_max_u32_accepted() {
3306        let toml_str = r#"
3307[models.opus]
3308model = "claude-opus-4-6"
3309autocompact = 4294967295
3310"#;
3311
3312        #[derive(Debug, Deserialize)]
3313        struct Wrapper {
3314            models: IndexMap<String, ModelAlias>,
3315        }
3316
3317        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3318        let alias = parsed.models.get("opus").unwrap();
3319        assert_eq!(alias.autocompact, Some(4294967295u32));
3320    }
3321
3322    #[test]
3323    fn model_alias_autocompact_overflow_errors() {
3324        // 4294967296 == u32::MAX + 1 — should be rejected
3325        let toml_str = r#"
3326[models.opus]
3327model = "claude-opus-4-6"
3328autocompact = 4294967296
3329"#;
3330
3331        #[derive(Debug, Deserialize)]
3332        struct Wrapper {
3333            #[allow(dead_code)]
3334            models: IndexMap<String, ModelAlias>,
3335        }
3336
3337        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3338        assert!(err.contains("out of u32 range"));
3339    }
3340
3341    #[test]
3342    fn both_autocompact_fields_round_trip() {
3343        let toml_str = r#"
3344[models.opus]
3345model = "claude-opus-4-6"
3346autocompact = 50000
3347autocompact_pct = 80
3348"#;
3349
3350        #[derive(Debug, Deserialize)]
3351        struct Wrapper {
3352            models: IndexMap<String, ModelAlias>,
3353        }
3354
3355        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3356        let alias = parsed.models.get("opus").unwrap();
3357        assert_eq!(alias.autocompact, Some(50000u32));
3358        assert_eq!(alias.autocompact_pct, Some(80u8));
3359
3360        // Verify both propagate through resolve_all
3361        let mut aliases = IndexMap::new();
3362        aliases.insert("opus".to_string(), alias.clone());
3363        let cache = ModelsCache {
3364            models: Vec::new(),
3365            fetched_at: None,
3366        };
3367        let mut diag = DiagnosticCollector::new();
3368        let resolved = resolve_all(&aliases, &cache, &mut diag);
3369        let entry = resolved.get("opus").unwrap();
3370        assert_eq!(entry.autocompact, Some(50000u32));
3371        assert_eq!(entry.autocompact_pct, Some(80u8));
3372    }
3373
3374    #[test]
3375    fn model_alias_both_model_and_match_is_hybrid_pinned() {
3376        let toml_str = r#"
3377[models.bad]
3378harness = "claude"
3379model = "some-model"
3380match = ["pattern-*"]
3381"#;
3382
3383        #[derive(Debug, Deserialize)]
3384        struct Wrapper {
3385            #[allow(dead_code)]
3386            models: IndexMap<String, ModelAlias>,
3387        }
3388
3389        let result = toml::from_str::<Wrapper>(toml_str).unwrap();
3390        let alias = result.models.get("bad").unwrap();
3391        match &alias.spec {
3392            ModelSpec::PinnedWithMatch {
3393                model,
3394                match_patterns,
3395                ..
3396            } => {
3397                assert_eq!(model, "some-model");
3398                assert_eq!(match_patterns, &["pattern-*"]);
3399            }
3400            _ => panic!("expected pinned-with-match alias"),
3401        }
3402    }
3403
3404    #[test]
3405    fn model_alias_neither_model_nor_match_errors() {
3406        let toml_str = r#"
3407[models.bad]
3408harness = "claude"
3409"#;
3410
3411        #[derive(Debug, Deserialize)]
3412        struct Wrapper {
3413            #[allow(dead_code)]
3414            models: IndexMap<String, ModelAlias>,
3415        }
3416
3417        let result = toml::from_str::<Wrapper>(toml_str);
3418        assert!(result.is_err());
3419    }
3420
3421    #[test]
3422    fn infer_provider_from_model_id_detects_known_prefixes() {
3423        assert_eq!(
3424            infer_provider_from_model_id("claude-opus-4-6"),
3425            Some("anthropic")
3426        );
3427        assert_eq!(
3428            infer_provider_from_model_id("gpt-5.3-codex"),
3429            Some("openai")
3430        );
3431        assert_eq!(
3432            infer_provider_from_model_id("gemini-2.5-pro"),
3433            Some("google")
3434        );
3435        assert_eq!(
3436            infer_provider_from_model_id("llama-4-maverick"),
3437            Some("meta")
3438        );
3439        assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
3440        assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
3441        assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
3442        assert_eq!(
3443            infer_provider_from_model_id("codex-mini-latest"),
3444            Some("openai")
3445        );
3446        assert_eq!(
3447            infer_provider_from_model_id("mistral-large"),
3448            Some("mistral")
3449        );
3450        assert_eq!(
3451            infer_provider_from_model_id("codestral-latest"),
3452            Some("mistral")
3453        );
3454        assert_eq!(
3455            infer_provider_from_model_id("deepseek-chat"),
3456            Some("deepseek")
3457        );
3458        assert_eq!(
3459            infer_provider_from_model_id("command-r-plus"),
3460            Some("cohere")
3461        );
3462    }
3463
3464    #[test]
3465    fn infer_provider_from_model_id_returns_none_for_unknown_model() {
3466        assert_eq!(infer_provider_from_model_id("unknown-model"), None);
3467    }
3468
3469    #[test]
3470    fn infer_provider_from_model_id_returns_none_for_empty_string() {
3471        assert_eq!(infer_provider_from_model_id(""), None);
3472    }
3473
3474    #[test]
3475    fn infer_provider_from_model_id_is_case_insensitive() {
3476        assert_eq!(
3477            infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
3478            Some("anthropic")
3479        );
3480        assert_eq!(
3481            infer_provider_from_model_id("GPT-5.3-codex"),
3482            Some("openai")
3483        );
3484        assert_eq!(
3485            infer_provider_from_model_id("CoDeStRaL-latest"),
3486            Some("mistral")
3487        );
3488    }
3489
3490    #[allow(unused_unsafe)]
3491    fn env_set(key: &str, value: &str) {
3492        unsafe {
3493            std::env::set_var(key, value);
3494        }
3495    }
3496
3497    #[allow(unused_unsafe)]
3498    fn env_remove(key: &str) {
3499        unsafe {
3500            std::env::remove_var(key);
3501        }
3502    }
3503
3504    struct EnvVarGuard {
3505        key: String,
3506        prev: Option<String>,
3507    }
3508
3509    impl EnvVarGuard {
3510        fn set(key: &str, value: &str) -> Self {
3511            let prev = std::env::var(key).ok();
3512            env_set(key, value);
3513            Self {
3514                key: key.to_string(),
3515                prev,
3516            }
3517        }
3518    }
3519
3520    impl Drop for EnvVarGuard {
3521        fn drop(&mut self) {
3522            if let Some(prev) = &self.prev {
3523                env_set(&self.key, prev);
3524            } else {
3525                env_remove(&self.key);
3526            }
3527        }
3528    }
3529
3530    fn sample_catalog_json() -> serde_json::Value {
3531        serde_json::json!({
3532            "openai": {
3533                "models": {
3534                    "gpt-5": {
3535                        "id": "gpt-5",
3536                        "name": "GPT-5",
3537                        "release_date": "2025-06-01",
3538                        "limit": {
3539                            "context": 400000,
3540                            "output": 128000
3541                        }
3542                    }
3543                }
3544            },
3545            "anthropic": {
3546                "models": {
3547                    "claude-sonnet-4-5": {
3548                        "id": "claude-sonnet-4-5",
3549                        "name": "Claude Sonnet 4.5",
3550                        "release_date": "2025-03-01"
3551                    }
3552                }
3553            }
3554        })
3555    }
3556
3557    fn sample_cached_model(id: &str) -> CachedModel {
3558        CachedModel {
3559            id: id.to_string(),
3560            provider: "OpenAI".to_string(),
3561            release_date: None,
3562            description: None,
3563            context_window: None,
3564            max_output: None,
3565            cost_input: None,
3566            cost_output: None,
3567            cost_cache_read: None,
3568            cost_cache_write: None,
3569            cost_reasoning: None,
3570        }
3571    }
3572
3573    fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
3574        write_cache(
3575            mars_dir,
3576            &ModelsCache {
3577                models,
3578                fetched_at: Some(fetched_at.to_string()),
3579            },
3580        )
3581        .expect("failed to write cache fixture");
3582    }
3583
3584    fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
3585        std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
3586        std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
3587    }
3588
3589    fn stale_timestamp() -> String {
3590        now_unix_secs_value().saturating_sub(48 * 3600).to_string()
3591    }
3592
3593    fn fresh_timestamp() -> String {
3594        now_unix_secs_value().saturating_sub(60).to_string()
3595    }
3596
3597    fn assert_model_cache_unavailable(
3598        result: Result<(ModelsCache, RefreshOutcome), MarsError>,
3599        reason_contains: &str,
3600    ) {
3601        match result {
3602            Err(MarsError::ModelCacheUnavailable { reason }) => {
3603                assert!(
3604                    reason.contains(reason_contains),
3605                    "unexpected reason: {reason}"
3606                );
3607            }
3608            other => panic!("expected ModelCacheUnavailable, got {other:?}"),
3609        }
3610    }
3611
3612    #[test]
3613    #[serial]
3614    fn ensure_fresh_1_missing_cache_offline_errors() {
3615        let mars = tempdir().unwrap();
3616        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3617
3618        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3619        assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
3620    }
3621
3622    #[test]
3623    #[serial]
3624    fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
3625        let mars = tempdir().unwrap();
3626        let server = MockServer::start();
3627        let mock = server.mock(|when, then| {
3628            when.method(GET).path("/api.json");
3629            then.status(500).body("server error");
3630        });
3631        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3632
3633        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3634        assert_model_cache_unavailable(result, "automatic refresh failed");
3635        assert_eq!(mock.hits(), 1);
3636    }
3637
3638    #[test]
3639    fn ensure_fresh_3_stale_usable_offline_returns_stale() {
3640        let mars = tempdir().unwrap();
3641        write_cache_state(
3642            mars.path(),
3643            vec![sample_cached_model("stale-model")],
3644            &stale_timestamp(),
3645        );
3646
3647        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
3648        assert_eq!(cache.models.len(), 1);
3649        assert_eq!(cache.models[0].id, "stale-model");
3650        assert_eq!(outcome, RefreshOutcome::Offline);
3651    }
3652
3653    #[test]
3654    #[serial]
3655    fn ensure_fresh_4_fresh_auto_skips_http() {
3656        let mars = tempdir().unwrap();
3657        write_cache_state(
3658            mars.path(),
3659            vec![sample_cached_model("fresh-model")],
3660            &fresh_timestamp(),
3661        );
3662
3663        let server = MockServer::start();
3664        let mock = server.mock(|when, then| {
3665            when.method(GET).path("/api.json");
3666            then.status(200).json_body(sample_catalog_json());
3667        });
3668        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3669
3670        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3671        assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
3672        assert_eq!(mock.hits(), 0);
3673    }
3674
3675    #[test]
3676    #[serial]
3677    fn ensure_fresh_5_stale_auto_success_refreshes() {
3678        let mars = tempdir().unwrap();
3679        write_cache_state(
3680            mars.path(),
3681            vec![sample_cached_model("old-model")],
3682            &stale_timestamp(),
3683        );
3684
3685        let server = MockServer::start();
3686        let mock = server.mock(|when, then| {
3687            when.method(GET).path("/api.json");
3688            then.status(200).json_body(sample_catalog_json());
3689        });
3690        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3691
3692        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3693        assert!(matches!(
3694            outcome,
3695            RefreshOutcome::Refreshed { models_count } if models_count == 2
3696        ));
3697        assert_eq!(cache.models.len(), 2);
3698        assert!(!cache.models.is_empty());
3699        assert!(cache.fetched_at.is_some());
3700        assert_eq!(mock.hits(), 1);
3701    }
3702
3703    #[test]
3704    #[serial]
3705    fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
3706        let mars = tempdir().unwrap();
3707        write_cache_state(
3708            mars.path(),
3709            vec![sample_cached_model("stale-model")],
3710            &stale_timestamp(),
3711        );
3712
3713        let server = MockServer::start();
3714        let mock = server.mock(|when, then| {
3715            when.method(GET).path("/api.json");
3716            then.status(500).body("server error");
3717        });
3718        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3719
3720        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3721        assert_eq!(cache.models[0].id, "stale-model");
3722        assert!(matches!(
3723            outcome,
3724            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
3725        ));
3726        assert_eq!(mock.hits(), 1);
3727    }
3728
3729    #[test]
3730    #[serial]
3731    fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
3732        let mars = tempdir().unwrap();
3733        write_cache_state(
3734            mars.path(),
3735            vec![sample_cached_model("stale-model")],
3736            &stale_timestamp(),
3737        );
3738
3739        let server = MockServer::start();
3740        let mock = server.mock(|when, then| {
3741            when.method(GET).path("/api.json");
3742            then.status(200).json_body(serde_json::json!({}));
3743        });
3744        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3745
3746        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3747        assert_eq!(cache.models[0].id, "stale-model");
3748        assert!(matches!(
3749            outcome,
3750            RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
3751        ));
3752        assert_eq!(mock.hits(), 1);
3753    }
3754
3755    #[test]
3756    #[serial]
3757    fn ensure_fresh_8_empty_cache_auto_refetches() {
3758        let mars = tempdir().unwrap();
3759        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
3760
3761        let server = MockServer::start();
3762        let mock = server.mock(|when, then| {
3763            when.method(GET).path("/api.json");
3764            then.status(200).json_body(sample_catalog_json());
3765        });
3766        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3767
3768        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3769        assert!(!cache.models.is_empty());
3770        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3771        assert_eq!(mock.hits(), 1);
3772    }
3773
3774    #[test]
3775    fn ensure_fresh_9_empty_cache_offline_errors() {
3776        let mars = tempdir().unwrap();
3777        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
3778
3779        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
3780        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
3781    }
3782
3783    #[test]
3784    #[serial]
3785    fn ensure_fresh_10_corrupt_json_auto_refetches() {
3786        let mars = tempdir().unwrap();
3787        write_raw_cache_file(mars.path(), "{ not-json ");
3788
3789        let server = MockServer::start();
3790        let mock = server.mock(|when, then| {
3791            when.method(GET).path("/api.json");
3792            then.status(200).json_body(sample_catalog_json());
3793        });
3794        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3795
3796        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3797        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3798        assert!(!cache.models.is_empty());
3799        assert_eq!(mock.hits(), 1);
3800    }
3801
3802    #[test]
3803    fn ensure_fresh_11_corrupt_json_offline_errors() {
3804        let mars = tempdir().unwrap();
3805        write_raw_cache_file(mars.path(), "{ not-json ");
3806
3807        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
3808        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
3809    }
3810
3811    #[test]
3812    fn read_cache_io_error_includes_operation_and_path() {
3813        let mars = tempdir().unwrap();
3814        let cache_path = mars.path().join(CACHE_FILE);
3815        std::fs::create_dir(&cache_path).unwrap();
3816
3817        let err = read_cache(mars.path()).unwrap_err();
3818        let msg = err.to_string();
3819
3820        assert!(
3821            msg.contains("read models cache"),
3822            "error should include operation context: {msg}"
3823        );
3824        assert!(
3825            msg.contains(CACHE_FILE),
3826            "error should include cache path: {msg}"
3827        );
3828    }
3829
3830    #[test]
3831    #[serial]
3832    fn ensure_fresh_12_ttl_zero_always_refetches() {
3833        let mars = tempdir().unwrap();
3834        write_cache_state(
3835            mars.path(),
3836            vec![sample_cached_model("fresh-model")],
3837            &fresh_timestamp(),
3838        );
3839
3840        let server = MockServer::start();
3841        let mock = server.mock(|when, then| {
3842            when.method(GET).path("/api.json");
3843            then.status(200).json_body(sample_catalog_json());
3844        });
3845        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3846
3847        let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
3848        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3849        assert_eq!(mock.hits(), 1);
3850    }
3851
3852    #[test]
3853    #[serial]
3854    fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
3855        let mars = tempdir().unwrap();
3856        write_cache_state(
3857            mars.path(),
3858            vec![sample_cached_model("stale-model")],
3859            "not-a-timestamp",
3860        );
3861
3862        let server = MockServer::start();
3863        let mock = server.mock(|when, then| {
3864            when.method(GET).path("/api.json");
3865            then.status(200).json_body(sample_catalog_json());
3866        });
3867        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3868
3869        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3870        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3871        assert_eq!(mock.hits(), 1);
3872    }
3873
3874    #[test]
3875    #[serial]
3876    fn ensure_fresh_14_future_fetched_at_is_stale() {
3877        let mars = tempdir().unwrap();
3878        let future = now_unix_secs_value() + 3600;
3879        write_cache_state(
3880            mars.path(),
3881            vec![sample_cached_model("future-model")],
3882            &future.to_string(),
3883        );
3884
3885        let server = MockServer::start();
3886        let mock = server.mock(|when, then| {
3887            when.method(GET).path("/api.json");
3888            then.status(200).json_body(sample_catalog_json());
3889        });
3890        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3891
3892        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3893        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3894        assert_eq!(mock.hits(), 1);
3895    }
3896
3897    #[test]
3898    #[serial]
3899    fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
3900        let mars = tempdir().unwrap();
3901        write_cache_state(
3902            mars.path(),
3903            vec![sample_cached_model("fresh-model")],
3904            &fresh_timestamp(),
3905        );
3906
3907        let server = MockServer::start();
3908        let mock = server.mock(|when, then| {
3909            when.method(GET).path("/api.json");
3910            then.status(200).json_body(sample_catalog_json());
3911        });
3912        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3913        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3914
3915        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3916        assert_eq!(outcome, RefreshOutcome::Offline);
3917        assert_eq!(mock.hits(), 0);
3918    }
3919
3920    #[test]
3921    #[serial]
3922    fn ensure_fresh_16_offline_env_zero_is_not_offline() {
3923        let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
3924        assert!(!is_mars_offline());
3925        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
3926    }
3927
3928    #[test]
3929    #[serial]
3930    fn ensure_fresh_17_offline_env_truthy_is_offline() {
3931        let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
3932        assert!(is_mars_offline());
3933        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
3934    }
3935
3936    #[test]
3937    #[serial]
3938    fn ensure_fresh_18_force_ignores_offline_env() {
3939        let mars = tempdir().unwrap();
3940        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3941
3942        let server = MockServer::start();
3943        let mock = server.mock(|when, then| {
3944            when.method(GET).path("/api.json");
3945            then.status(200).json_body(sample_catalog_json());
3946        });
3947        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3948
3949        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
3950        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3951        assert_eq!(mock.hits(), 1);
3952    }
3953
3954    #[test]
3955    #[serial]
3956    fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
3957        let mars = tempdir().unwrap();
3958        write_cache_state(
3959            mars.path(),
3960            vec![sample_cached_model("stale-model")],
3961            &stale_timestamp(),
3962        );
3963
3964        let path = Arc::new(mars.path().to_path_buf());
3965        let path_a = Arc::clone(&path);
3966        let path_b = Arc::clone(&path);
3967        let fetch_hits = Arc::new(AtomicUsize::new(0));
3968        let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
3969        let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
3970
3971        let fetch_hits_a = Arc::clone(&fetch_hits);
3972        let t1 = thread::spawn(move || {
3973            ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
3974                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
3975                fetch_started_tx.send(()).unwrap();
3976                release_fetch_rx.recv().unwrap();
3977                Ok(vec![sample_cached_model("fresh-model")])
3978            })
3979            .unwrap()
3980            .1
3981        });
3982
3983        fetch_started_rx.recv().unwrap();
3984
3985        let fetch_hits_b = Arc::clone(&fetch_hits);
3986        let t2 = thread::spawn(move || {
3987            ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
3988                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
3989                Ok(vec![sample_cached_model("unexpected-second-refresh")])
3990            })
3991            .unwrap()
3992            .1
3993        });
3994
3995        release_fetch_tx.send(()).unwrap();
3996
3997        let outcome_a = t1.join().unwrap();
3998        let outcome_b = t2.join().unwrap();
3999
4000        let outcomes = [outcome_a, outcome_b];
4001        let refreshed = outcomes
4002            .iter()
4003            .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
4004            .count();
4005        let already_fresh = outcomes
4006            .iter()
4007            .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
4008            .count();
4009
4010        assert_eq!(refreshed, 1);
4011        assert_eq!(already_fresh, 1);
4012        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4013    }
4014
4015    #[test]
4016    #[serial]
4017    fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
4018        let mars = tempdir().unwrap();
4019        write_cache_state(
4020            mars.path(),
4021            vec![sample_cached_model("stale-model")],
4022            &stale_timestamp(),
4023        );
4024
4025        let fetch_hits = Arc::new(AtomicUsize::new(0));
4026
4027        let fetch_hits_a = Arc::clone(&fetch_hits);
4028        let (_cache_a, outcome_a) =
4029            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4030                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4031                Err(MarsError::Http {
4032                    url: "https://example.test/api.json".to_string(),
4033                    status: 500,
4034                    message: "request failed with HTTP status 500".to_string(),
4035                })
4036            })
4037            .unwrap();
4038
4039        let fetch_hits_b = Arc::clone(&fetch_hits);
4040        let (_cache_b, outcome_b) =
4041            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4042                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4043                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4044            })
4045            .unwrap();
4046
4047        assert!(matches!(
4048            outcome_a,
4049            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
4050        ));
4051        assert_eq!(
4052            outcome_b,
4053            RefreshOutcome::StaleFallback {
4054                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4055            }
4056        );
4057        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4058    }
4059
4060    #[test]
4061    #[serial]
4062    fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
4063        let mars = tempdir().unwrap();
4064        write_cache_state(
4065            mars.path(),
4066            vec![sample_cached_model("stale-model")],
4067            &stale_timestamp(),
4068        );
4069
4070        let fetch_hits = Arc::new(AtomicUsize::new(0));
4071
4072        let fetch_hits_a = Arc::clone(&fetch_hits);
4073        let (_cache_a, outcome_a) =
4074            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4075                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4076                Ok(Vec::new())
4077            })
4078            .unwrap();
4079
4080        let fetch_hits_b = Arc::clone(&fetch_hits);
4081        let (_cache_b, outcome_b) =
4082            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4083                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4084                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4085            })
4086            .unwrap();
4087
4088        assert!(matches!(
4089            outcome_a,
4090            RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
4091        ));
4092        assert_eq!(
4093            outcome_b,
4094            RefreshOutcome::StaleFallback {
4095                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4096            }
4097        );
4098        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4099    }
4100
4101    #[test]
4102    fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
4103        let project = tempdir().unwrap();
4104        let ctx = crate::types::MarsContext::for_test(
4105            project.path().to_path_buf(),
4106            project.path().join(".agents"),
4107        );
4108        assert_eq!(load_models_cache_ttl(&ctx), 24);
4109    }
4110
4111    #[test]
4112    fn load_models_cache_ttl_reads_config_value() {
4113        let project = tempdir().unwrap();
4114        std::fs::write(
4115            project.path().join("mars.toml"),
4116            "[settings]\nmodels_cache_ttl_hours = 48\n",
4117        )
4118        .unwrap();
4119        let ctx = crate::types::MarsContext::for_test(
4120            project.path().to_path_buf(),
4121            project.path().join(".agents"),
4122        );
4123        assert_eq!(load_models_cache_ttl(&ctx), 48);
4124    }
4125}