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