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