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 harness;
23
24mod tracing {
25 macro_rules! debug {
26 ($($arg:tt)*) => {
27 if cfg!(debug_assertions) {
28 eprintln!($($arg)*);
29 }
30 };
31 }
32
33 pub(super) use debug;
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize)]
43pub struct ModelAlias {
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub harness: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub description: Option<String>,
48 #[serde(flatten)]
49 pub spec: ModelSpec,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub enum ModelSpec {
55 Pinned {
57 model: String,
58 provider: Option<String>,
59 },
60 AutoResolve {
62 provider: String,
63 match_patterns: Vec<String>,
64 exclude_patterns: Vec<String>,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize)]
70#[serde(rename_all = "snake_case")]
71pub enum HarnessSource {
72 Explicit,
73 AutoDetected,
74 Unavailable,
75}
76
77#[derive(Debug, Clone, Serialize)]
79pub struct ResolvedAlias {
80 pub name: String,
81 pub model_id: String,
82 pub provider: String,
83 pub harness: Option<String>,
84 pub harness_source: HarnessSource,
85 pub harness_candidates: Vec<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub description: Option<String>,
88}
89
90impl Serialize for ModelSpec {
92 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
93 use serde::ser::SerializeMap;
94 match self {
95 ModelSpec::Pinned { model, provider } => {
96 let mut count = 1;
97 if provider.is_some() {
98 count += 1;
99 }
100 let mut map = serializer.serialize_map(Some(count))?;
101 map.serialize_entry("model", model)?;
102 if let Some(provider) = provider {
103 map.serialize_entry("provider", provider)?;
104 }
105 map.end()
106 }
107 ModelSpec::AutoResolve {
108 provider,
109 match_patterns,
110 exclude_patterns,
111 } => {
112 let mut count = 2; if !exclude_patterns.is_empty() {
114 count += 1;
115 }
116 let mut map = serializer.serialize_map(Some(count))?;
117 map.serialize_entry("provider", provider)?;
118 map.serialize_entry("match", match_patterns)?;
119 if !exclude_patterns.is_empty() {
120 map.serialize_entry("exclude", exclude_patterns)?;
121 }
122 map.end()
123 }
124 }
125 }
126}
127
128#[derive(Debug, Deserialize)]
130struct RawModelAlias {
131 harness: Option<String>,
132 #[serde(default)]
133 description: Option<String>,
134 #[serde(default)]
136 model: Option<String>,
137 #[serde(default)]
139 provider: Option<String>,
140 #[serde(default, rename = "match")]
141 match_patterns: Option<Vec<String>>,
142 #[serde(default)]
143 exclude: Option<Vec<String>>,
144}
145
146impl<'de> Deserialize<'de> for ModelAlias {
147 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
148 let raw = RawModelAlias::deserialize(deserializer)?;
149
150 let has_model = raw.model.is_some();
151 let has_match = raw.match_patterns.is_some();
152
153 if has_model && has_match {
154 return Err(serde::de::Error::custom(
155 "model alias cannot have both 'model' and 'match' — use one or the other",
156 ));
157 }
158
159 let spec = if let Some(model) = raw.model {
160 ModelSpec::Pinned {
161 model,
162 provider: raw.provider,
163 }
164 } else if let Some(match_patterns) = raw.match_patterns {
165 let provider = raw.provider.ok_or_else(|| {
166 serde::de::Error::custom(
167 "auto-resolve model alias requires 'provider' when 'match' is specified",
168 )
169 })?;
170 ModelSpec::AutoResolve {
171 provider,
172 match_patterns,
173 exclude_patterns: raw.exclude.unwrap_or_default(),
174 }
175 } else {
176 return Err(serde::de::Error::custom(
177 "model alias must have either 'model' (pinned) or 'match' (auto-resolve)",
178 ));
179 };
180
181 Ok(ModelAlias {
182 harness: raw.harness,
183 description: raw.description,
184 spec,
185 })
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ModelsCache {
196 pub models: Vec<CachedModel>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub fetched_at: Option<String>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct CachedModel {
204 pub id: String,
205 pub provider: String,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub release_date: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub description: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub context_window: Option<u64>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub max_output: Option<u64>,
214}
215
216const CACHE_FILE: &str = "models-cache.json";
217const FETCH_FAIL_MARKER_FILE: &str = ".models-cache.last-fail";
218const DEFAULT_MODELS_CACHE_TTL_HOURS: u32 = 24;
219pub(crate) const FETCH_FAIL_COOLDOWN_SECS: u64 = 300;
220const FETCH_FAIL_COOLDOWN_REASON: &str = "recent fetch attempt failed; backing off (cooldown)";
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum RefreshMode {
224 Auto,
225 Force,
226 Offline,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum RefreshOutcome {
231 AlreadyFresh,
232 Refreshed { models_count: usize },
233 StaleFallback { reason: String },
234 Offline,
235}
236
237pub fn now_unix_secs_value() -> u64 {
238 SystemTime::now()
239 .duration_since(UNIX_EPOCH)
240 .unwrap_or_default()
241 .as_secs()
242}
243
244pub fn now_unix_secs() -> String {
245 now_unix_secs_value().to_string()
246}
247
248pub fn is_mars_offline() -> bool {
249 match std::env::var("MARS_OFFLINE") {
250 Ok(value) => matches!(
251 value.trim().to_ascii_lowercase().as_str(),
252 "1" | "true" | "yes"
253 ),
254 Err(_) => false,
255 }
256}
257
258pub fn resolve_refresh_mode(no_refresh_flag: bool) -> RefreshMode {
259 if no_refresh_flag {
260 RefreshMode::Offline
261 } else {
262 RefreshMode::Auto
263 }
264}
265
266pub fn load_models_cache_ttl(ctx: &MarsContext) -> u32 {
267 crate::config::load(&ctx.project_root)
268 .map(|config| config.settings.models_cache_ttl_hours)
269 .unwrap_or(DEFAULT_MODELS_CACHE_TTL_HOURS)
270}
271
272fn read_cache_tolerant(mars_dir: &Path) -> ModelsCache {
273 match read_cache(mars_dir) {
274 Ok(cache) => cache,
275 Err(err) => {
276 tracing::debug!("models cache read failed, treating as empty: {err}");
277 ModelsCache {
278 models: Vec::new(),
279 fetched_at: None,
280 }
281 }
282 }
283}
284
285fn is_fresh(cache: &ModelsCache, ttl_hours: u32) -> bool {
286 if ttl_hours == 0 {
287 return false;
288 }
289 if cache.models.is_empty() {
290 return false;
291 }
292
293 let Some(fetched_str) = &cache.fetched_at else {
294 return false;
295 };
296 let Ok(fetched) = fetched_str.parse::<u64>() else {
297 return false;
298 };
299
300 let now = now_unix_secs_value();
301 if fetched > now {
302 return false;
303 }
304
305 (now - fetched) < (ttl_hours as u64) * 3600
306}
307
308fn is_usable(cache: &ModelsCache) -> bool {
309 !cache.models.is_empty()
310}
311
312fn read_fetch_fail_marker(mars_dir: &Path) -> Option<u64> {
313 let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
314 let raw = std::fs::read_to_string(marker).ok()?;
315 raw.trim().parse::<u64>().ok()
316}
317
318fn write_fetch_fail_marker(mars_dir: &Path, timestamp: u64) {
319 let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
320 if let Err(err) = crate::fs::atomic_write(&marker, timestamp.to_string().as_bytes()) {
321 tracing::debug!("failed to write models fetch failure marker: {err}");
322 }
323}
324
325fn clear_fetch_fail_marker(mars_dir: &Path) {
326 let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
327 if let Err(err) = std::fs::remove_file(marker)
328 && err.kind() != std::io::ErrorKind::NotFound
329 {
330 tracing::debug!("failed to clear models fetch failure marker: {err}");
331 }
332}
333
334pub fn ensure_fresh(
335 mars_dir: &Path,
336 ttl_hours: u32,
337 mode: RefreshMode,
338) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
339 ensure_fresh_with_fetcher(mars_dir, ttl_hours, mode, fetch_models)
340}
341
342fn ensure_fresh_with_fetcher<F>(
343 mars_dir: &Path,
344 ttl_hours: u32,
345 mode: RefreshMode,
346 fetcher: F,
347) -> Result<(ModelsCache, RefreshOutcome), MarsError>
348where
349 F: FnOnce() -> Result<Vec<CachedModel>, MarsError>,
350{
351 std::fs::create_dir_all(mars_dir)?;
352
353 let effective_mode = match mode {
355 RefreshMode::Auto if is_mars_offline() => RefreshMode::Offline,
356 m => m,
357 };
358
359 let prior = read_cache_tolerant(mars_dir);
360
361 if effective_mode == RefreshMode::Auto && is_fresh(&prior, ttl_hours) {
362 return Ok((prior, RefreshOutcome::AlreadyFresh));
363 }
364
365 if effective_mode == RefreshMode::Offline {
366 if is_usable(&prior) {
367 return Ok((prior, RefreshOutcome::Offline));
368 }
369 return Err(MarsError::ModelCacheUnavailable {
370 reason: offline_unavailable_reason(mode),
371 });
372 }
373
374 let lock_path = mars_dir.join(".models-cache.lock");
375 let _guard = crate::fs::FileLock::acquire(&lock_path)?;
376
377 let under_lock = read_cache_tolerant(mars_dir);
378 if effective_mode == RefreshMode::Auto && is_fresh(&under_lock, ttl_hours) {
379 return Ok((under_lock, RefreshOutcome::AlreadyFresh));
380 }
381
382 if mode != RefreshMode::Force && is_usable(&under_lock) {
383 let now = now_unix_secs_value();
384 if let Some(last_fail) = read_fetch_fail_marker(mars_dir)
385 && now.saturating_sub(last_fail) < FETCH_FAIL_COOLDOWN_SECS
386 {
387 return Ok((
388 under_lock,
389 RefreshOutcome::StaleFallback {
390 reason: FETCH_FAIL_COOLDOWN_REASON.to_string(),
391 },
392 ));
393 }
394 }
395
396 match fetcher() {
397 Ok(models) if !models.is_empty() => {
398 let models_count = models.len();
399 let cache = ModelsCache {
400 models,
401 fetched_at: Some(now_unix_secs()),
402 };
403 write_cache(mars_dir, &cache)?;
404 clear_fetch_fail_marker(mars_dir);
405 Ok((cache, RefreshOutcome::Refreshed { models_count }))
406 }
407 Ok(_) => fallback_to_stale_or_error(
408 mars_dir,
409 under_lock,
410 "API returned empty catalog".to_string(),
411 "API returned an empty catalog and no prior cache exists".to_string(),
412 true,
413 ),
414 Err(err) => fallback_to_stale_or_error(
415 mars_dir,
416 under_lock,
417 format!("fetch failed: {err}"),
418 format!("automatic refresh failed: {err}"),
419 true,
420 ),
421 }
422}
423
424fn fallback_to_stale_or_error(
425 mars_dir: &Path,
426 under_lock: ModelsCache,
427 stale_reason: String,
428 unavailable_reason: String,
429 mark_fetch_failure: bool,
430) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
431 if is_usable(&under_lock) {
432 if mark_fetch_failure {
433 write_fetch_fail_marker(mars_dir, now_unix_secs_value());
434 }
435 Ok((
436 under_lock,
437 RefreshOutcome::StaleFallback {
438 reason: stale_reason,
439 },
440 ))
441 } else {
442 Err(MarsError::ModelCacheUnavailable {
443 reason: unavailable_reason,
444 })
445 }
446}
447
448fn offline_unavailable_reason(requested_mode: RefreshMode) -> String {
449 match requested_mode {
450 RefreshMode::Offline => {
451 "--no-refresh-models was passed and no cached catalog is available".to_string()
452 }
453 RefreshMode::Auto => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
454 RefreshMode::Force => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
455 }
456}
457
458pub fn read_cache(mars_dir: &Path) -> Result<ModelsCache, MarsError> {
460 let path = mars_dir.join(CACHE_FILE);
461 match std::fs::read_to_string(&path) {
462 Ok(content) => {
463 let cache: ModelsCache =
464 serde_json::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
465 message: format!("failed to parse models cache: {e}"),
466 })?;
467 Ok(cache)
468 }
469 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsCache {
470 models: Vec::new(),
471 fetched_at: None,
472 }),
473 Err(e) => Err(MarsError::Io(e)),
474 }
475}
476
477pub fn write_cache(mars_dir: &Path, cache: &ModelsCache) -> Result<(), MarsError> {
479 std::fs::create_dir_all(mars_dir)?;
480 let path = mars_dir.join(CACHE_FILE);
481 let tmp_path = mars_dir.join(".models-cache.json.tmp");
482 let content =
483 serde_json::to_string_pretty(cache).map_err(|e| crate::error::ConfigError::Invalid {
484 message: format!("failed to serialize models cache: {e}"),
485 })?;
486 std::fs::write(&tmp_path, content)?;
487 std::fs::rename(&tmp_path, &path)?;
488 Ok(())
489}
490
491pub fn fetch_models() -> Result<Vec<CachedModel>, MarsError> {
496 let url = models_api_url();
497 let agent: ureq::Agent = ureq::Agent::config_builder()
498 .timeout_connect(Some(Duration::from_secs(15)))
499 .timeout_recv_response(Some(Duration::from_secs(15)))
500 .timeout_recv_body(Some(Duration::from_secs(15)))
501 .build()
502 .into();
503
504 let response = agent.get(&url).call().map_err(|e| match e {
505 ureq::Error::StatusCode(status) => MarsError::Http {
506 url: url.clone(),
507 status,
508 message: format!("request failed with HTTP status {status}"),
509 },
510 _ => MarsError::Http {
511 url: url.clone(),
512 status: 0,
513 message: format!("failed to fetch models catalog: {e}"),
514 },
515 })?;
516 let body = response
517 .into_body()
518 .read_to_string()
519 .map_err(|e| MarsError::Http {
520 url: url.clone(),
521 status: 0,
522 message: format!("failed to read response body: {e}"),
523 })?;
524 let raw: serde_json::Value =
525 serde_json::from_str(&body).map_err(|e| crate::error::ConfigError::Invalid {
526 message: format!("failed to parse models API response: {e}"),
527 })?;
528
529 parse_models_dev_catalog(&raw)
530}
531
532fn models_api_url() -> String {
533 std::env::var("MARS_MODELS_API_URL").unwrap_or_else(|_| "https://models.dev/api.json".into())
534}
535
536fn parse_models_dev_catalog(raw: &serde_json::Value) -> Result<Vec<CachedModel>, MarsError> {
537 let providers = raw
538 .as_object()
539 .ok_or_else(|| crate::error::ConfigError::Invalid {
540 message: "models API response must be an object keyed by provider".to_string(),
541 })?;
542
543 let mut models = Vec::new();
544
545 for (provider_key, provider_obj) in providers {
546 if !is_major_provider(provider_key) {
547 continue;
548 }
549
550 let Some(provider_models) = provider_obj.get("models").and_then(|m| m.as_object()) else {
551 continue;
552 };
553
554 for model_obj in provider_models.values() {
555 let Some(model_id) = model_obj.get("id").and_then(|v| v.as_str()) else {
556 continue;
557 };
558 let release_date = model_obj
559 .get("release_date")
560 .and_then(|v| v.as_str())
561 .map(str::to_string);
562 let description = model_obj
563 .get("name")
564 .and_then(|v| v.as_str())
565 .map(str::to_string);
566 let context_window = model_obj
567 .get("limit")
568 .and_then(|v| v.get("context"))
569 .and_then(|v| v.as_u64());
570 let max_output = model_obj
571 .get("limit")
572 .and_then(|v| v.get("output"))
573 .and_then(|v| v.as_u64());
574
575 models.push(CachedModel {
576 id: model_id.to_string(),
577 provider: normalize_provider(provider_key),
578 release_date,
579 description,
580 context_window,
581 max_output,
582 });
583 }
584 }
585
586 Ok(models)
587}
588
589fn is_major_provider(provider_key: &str) -> bool {
590 matches!(
591 provider_key,
592 "anthropic"
593 | "openai"
594 | "google"
595 | "meta-llama"
596 | "meta"
597 | "mistralai"
598 | "mistral"
599 | "deepseek"
600 | "cohere"
601 )
602}
603
604fn normalize_provider(slug: &str) -> String {
606 match slug {
607 "anthropic" => "Anthropic".to_string(),
608 "openai" => "OpenAI".to_string(),
609 "google" => "Google".to_string(),
610 "meta-llama" | "meta" => "Meta".to_string(),
611 "mistralai" | "mistral" => "Mistral".to_string(),
612 "deepseek" => "DeepSeek".to_string(),
613 "cohere" => "Cohere".to_string(),
614 _ => slug.to_string(),
615 }
616}
617
618pub fn auto_resolve(
632 provider: &str,
633 match_patterns: &[String],
634 exclude_patterns: &[String],
635 cache: &ModelsCache,
636) -> Option<String> {
637 let mut candidates: Vec<&CachedModel> = cache
638 .models
639 .iter()
640 .filter(|m| {
641 m.provider.eq_ignore_ascii_case(provider)
643 })
644 .filter(|m| {
645 !m.id.ends_with("-latest")
647 })
648 .filter(|m| {
649 match_patterns.iter().all(|p| glob_match(p, &m.id))
651 })
652 .filter(|m| {
653 !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
655 })
656 .collect();
657
658 candidates.sort_by(|a, b| {
660 let date_cmp = b
661 .release_date
662 .as_deref()
663 .unwrap_or("")
664 .cmp(a.release_date.as_deref().unwrap_or(""));
665 date_cmp.then_with(|| a.id.len().cmp(&b.id.len()))
666 });
667
668 candidates.first().map(|m| m.id.clone())
669}
670
671pub fn glob_match(pattern: &str, text: &str) -> bool {
674 let segments: Vec<&str> = pattern.split('*').collect();
676
677 if segments.len() == 1 {
678 return pattern == text;
680 }
681
682 let mut pos = 0;
683
684 if let Some(first) = segments.first()
686 && !first.is_empty()
687 {
688 if !text.starts_with(first) {
689 return false;
690 }
691 pos = first.len();
692 }
693
694 if let Some(last) = segments.last()
696 && !last.is_empty()
697 && !text[pos..].ends_with(last)
698 {
699 return false;
700 }
701
702 let end = if let Some(last) = segments.last() {
704 if !last.is_empty() {
705 text.len() - last.len()
706 } else {
707 text.len()
708 }
709 } else {
710 text.len()
711 };
712
713 for segment in &segments[1..segments.len().saturating_sub(1)] {
714 if segment.is_empty() {
715 continue;
716 }
717 if let Some(idx) = text[pos..end].find(segment) {
718 pos += idx + segment.len();
719 } else {
720 return false;
721 }
722 }
723
724 pos <= end
725}
726
727pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
735 let mut m = IndexMap::new();
736 let add = |m: &mut IndexMap<String, ModelAlias>,
737 name: &str,
738 provider: &str,
739 match_patterns: &[&str],
740 exclude: &[&str]| {
741 m.insert(
742 name.to_string(),
743 ModelAlias {
744 harness: None,
745 description: None,
746 spec: ModelSpec::AutoResolve {
747 provider: provider.to_string(),
748 match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
749 exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
750 },
751 },
752 );
753 };
754 add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
755 add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
756 add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
757 add(
758 &mut m,
759 "codex",
760 "openai",
761 &["*codex*"],
762 &["*-mini", "*-spark", "*-max"],
763 );
764 add(
765 &mut m,
766 "gpt",
767 "openai",
768 &["gpt-5*"],
769 &["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
770 );
771 add(
772 &mut m,
773 "gemini",
774 "google",
775 &["gemini*", "*pro*"],
776 &["*-customtools"],
777 );
778 m
779}
780
781pub struct ResolvedDepModels {
787 pub source_name: String,
788 pub models: IndexMap<String, ModelAlias>,
789}
790
791pub fn merge_model_config(
797 consumer: &IndexMap<String, ModelAlias>,
798 deps: &[ResolvedDepModels],
799 diag: &mut DiagnosticCollector,
800) -> IndexMap<String, ModelAlias> {
801 let mut merged = IndexMap::new();
802 let builtins = builtin_aliases();
803
804 for (name, alias) in &builtins {
806 merged.insert(name.clone(), alias.clone());
807 }
808
809 let mut dep_provided: std::collections::HashSet<String> = std::collections::HashSet::new();
811
812 for dep in deps {
814 for (name, alias) in &dep.models {
815 if consumer.contains_key(name) {
816 continue;
818 }
819 if dep_provided.contains(name) {
820 diag.warn_with_context(
822 "model-alias-conflict",
823 format!(
824 "model alias `{name}` defined by both `{}` and earlier dependency — using earlier definition",
825 dep.source_name
826 ),
827 dep.source_name.clone(),
828 );
829 } else {
830 merged.insert(name.clone(), alias.clone());
832 dep_provided.insert(name.clone());
833 }
834 }
835 }
836
837 for (name, alias) in consumer {
839 merged.insert(name.clone(), alias.clone());
840 }
841
842 merged
843}
844
845pub fn resolve_all(
849 aliases: &IndexMap<String, ModelAlias>,
850 cache: &ModelsCache,
851) -> IndexMap<String, ResolvedAlias> {
852 let installed = harness::detect_installed_harnesses();
853 let mut resolved = IndexMap::new();
854
855 for (name, alias) in aliases {
856 let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
857 continue; };
859
860 let candidates = harness::harness_candidates_for_provider(&provider);
861 let (h, source) = resolve_harness(alias, &provider, &installed);
862
863 resolved.insert(
864 name.clone(),
865 ResolvedAlias {
866 name: name.clone(),
867 model_id,
868 provider,
869 harness: h,
870 harness_source: source,
871 harness_candidates: candidates,
872 description: alias.description.clone(),
873 },
874 );
875 }
876
877 resolved
878}
879
880pub fn filter_by_visibility(
885 mut aliases: IndexMap<String, ResolvedAlias>,
886 visibility: &crate::config::ModelVisibility,
887) -> IndexMap<String, ResolvedAlias> {
888 if let Some(includes) = &visibility.include {
889 aliases.retain(|name, _| includes.iter().any(|p| glob_match(p, name)));
890 } else if let Some(excludes) = &visibility.exclude {
891 aliases.retain(|name, _| !excludes.iter().any(|p| glob_match(p, name)));
892 }
893 aliases
894}
895
896fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
897 match &alias.spec {
898 ModelSpec::Pinned { model, provider } => {
899 let p = provider
900 .clone()
901 .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
902 .unwrap_or_else(|| "unknown".to_string());
903 Some((model.clone(), p))
904 }
905 ModelSpec::AutoResolve {
906 provider,
907 match_patterns,
908 exclude_patterns,
909 } => {
910 let id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
911 Some((id, provider.clone()))
912 }
913 }
914}
915
916fn resolve_harness(
917 alias: &ModelAlias,
918 provider: &str,
919 installed: &HashSet<String>,
920) -> (Option<String>, HarnessSource) {
921 if let Some(h) = &alias.harness {
922 if installed.contains(h) {
923 (Some(h.clone()), HarnessSource::Explicit)
924 } else {
925 (Some(h.clone()), HarnessSource::Unavailable)
926 }
927 } else {
928 match harness::resolve_harness_for_provider(provider, installed) {
929 Some(h) => (Some(h), HarnessSource::AutoDetected),
930 None => (None, HarnessSource::Unavailable),
931 }
932 }
933}
934
935#[allow(dead_code)]
938fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
939 let id = model_id.to_lowercase();
940 if id.starts_with("claude-") {
941 return Some("anthropic");
942 }
943 if id.starts_with("gpt-")
944 || id.starts_with("o1")
945 || id.starts_with("o3")
946 || id.starts_with("o4")
947 || id.starts_with("codex-")
948 {
949 return Some("openai");
950 }
951 if id.starts_with("gemini") {
952 return Some("google");
953 }
954 if id.starts_with("llama") {
955 return Some("meta");
956 }
957 if id.starts_with("mistral") || id.starts_with("codestral") {
958 return Some("mistral");
959 }
960 if id.starts_with("deepseek") {
961 return Some("deepseek");
962 }
963 if id.starts_with("command") {
964 return Some("cohere");
965 }
966 None
967}
968
969#[cfg(test)]
974mod tests {
975 use super::*;
976 use httpmock::prelude::*;
977 use std::collections::HashSet;
978 use std::sync::atomic::{AtomicUsize, Ordering};
979 use std::sync::{Arc, mpsc};
980 use std::thread;
981 use tempfile::tempdir;
982
983 use serial_test::serial;
984
985 #[test]
986 fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
987 let raw = serde_json::json!({
988 "anthropic": {
989 "models": {
990 "claude-opus-4-6": {
991 "id": "claude-opus-4-6",
992 "name": "Claude Opus 4.6",
993 "release_date": "2026-02-05",
994 "limit": {
995 "context": 1000000,
996 "output": 128000
997 }
998 }
999 }
1000 },
1001 "openai": {
1002 "models": {
1003 "gpt-5": {
1004 "id": "gpt-5",
1005 "name": "GPT-5"
1006 }
1007 }
1008 },
1009 "random-host": {
1010 "models": {
1011 "foo": {
1012 "id": "foo"
1013 }
1014 }
1015 }
1016 });
1017
1018 let models = parse_models_dev_catalog(&raw).unwrap();
1019 assert_eq!(models.len(), 2);
1020
1021 let opus = models
1022 .iter()
1023 .find(|m| m.id == "claude-opus-4-6")
1024 .expect("missing claude-opus-4-6");
1025 assert_eq!(opus.provider, "Anthropic");
1026 assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1027 assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1028 assert_eq!(opus.context_window, Some(1_000_000));
1029 assert_eq!(opus.max_output, Some(128_000));
1030
1031 let gpt = models
1032 .iter()
1033 .find(|m| m.id == "gpt-5")
1034 .expect("missing gpt-5");
1035 assert_eq!(gpt.provider, "OpenAI");
1036 assert_eq!(gpt.release_date, None);
1037 assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1038 assert_eq!(gpt.context_window, None);
1039 assert_eq!(gpt.max_output, None);
1040 }
1041
1042 #[test]
1043 fn parse_models_dev_catalog_requires_object_root() {
1044 let raw = serde_json::json!(["not", "an", "object"]);
1045 let err = parse_models_dev_catalog(&raw).unwrap_err();
1046 assert!(err.to_string().contains("keyed by provider"));
1047 }
1048
1049 #[test]
1052 fn glob_exact_match() {
1053 assert!(glob_match("claude-opus-4", "claude-opus-4"));
1054 assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1055 }
1056
1057 #[test]
1058 fn glob_star_suffix() {
1059 assert!(glob_match("claude-opus-*", "claude-opus-4"));
1060 assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1061 assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1062 }
1063
1064 #[test]
1065 fn glob_star_prefix() {
1066 assert!(glob_match("*-opus-4", "claude-opus-4"));
1067 assert!(!glob_match("*-opus-4", "claude-opus-5"));
1068 }
1069
1070 #[test]
1071 fn glob_star_middle() {
1072 assert!(glob_match("claude-*-4", "claude-opus-4"));
1073 assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1074 assert!(!glob_match("claude-*-4", "claude-opus-5"));
1075 }
1076
1077 #[test]
1078 fn glob_multiple_stars() {
1079 assert!(glob_match("*claude*opus*", "claude-opus-4"));
1080 assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1081 assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1082 }
1083
1084 #[test]
1085 fn glob_star_only() {
1086 assert!(glob_match("*", "anything"));
1087 assert!(glob_match("*", ""));
1088 }
1089
1090 #[test]
1091 fn glob_empty_pattern() {
1092 assert!(glob_match("", ""));
1093 assert!(!glob_match("", "something"));
1094 }
1095
1096 fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1099 ModelsCache {
1100 models: models
1101 .into_iter()
1102 .map(|(id, provider, date)| CachedModel {
1103 id: id.to_string(),
1104 provider: provider.to_string(),
1105 release_date: date.map(String::from),
1106 description: None,
1107 context_window: None,
1108 max_output: None,
1109 })
1110 .collect(),
1111 fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1112 }
1113 }
1114
1115 #[test]
1116 fn auto_resolve_basic() {
1117 let cache = make_cache(vec![
1118 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1119 ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1120 ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1121 ]);
1122
1123 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1124 assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
1126 }
1127
1128 #[test]
1129 fn auto_resolve_exclude() {
1130 let cache = make_cache(vec![
1131 ("gpt-5", "OpenAI", Some("2025-06-01")),
1132 ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
1133 ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
1134 ]);
1135
1136 let result = auto_resolve(
1137 "OpenAI",
1138 &["gpt-*".to_string()],
1139 &["gpt-3*".to_string(), "gpt-4o*".to_string()],
1140 &cache,
1141 );
1142 assert_eq!(result, Some("gpt-5".to_string()));
1143 }
1144
1145 #[test]
1146 fn auto_resolve_skip_latest() {
1147 let cache = make_cache(vec![
1148 ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1149 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1150 ]);
1151
1152 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1153 assert_eq!(result, Some("claude-opus-4".to_string()));
1155 }
1156
1157 #[test]
1158 fn auto_resolve_empty_cache() {
1159 let cache = ModelsCache {
1160 models: Vec::new(),
1161 fetched_at: None,
1162 };
1163
1164 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1165 assert_eq!(result, None);
1166 }
1167
1168 #[test]
1169 fn auto_resolve_no_match() {
1170 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1171
1172 let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
1173 assert_eq!(result, None);
1174 }
1175
1176 #[test]
1177 fn auto_resolve_provider_case_insensitive() {
1178 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1179
1180 let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
1181 assert_eq!(result, Some("claude-opus-4".to_string()));
1182 }
1183
1184 #[test]
1185 fn auto_resolve_shortest_id_tiebreaker() {
1186 let cache = make_cache(vec![
1187 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1188 ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
1189 ]);
1190
1191 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1192 assert_eq!(result, Some("claude-opus-4".to_string()));
1194 }
1195
1196 fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
1199 ModelAlias {
1200 harness: harness.map(|h| h.to_string()),
1201 description: None,
1202 spec: ModelSpec::Pinned {
1203 model: model.to_string(),
1204 provider: None,
1205 },
1206 }
1207 }
1208
1209 #[test]
1210 fn merge_empty_returns_builtins() {
1211 let mut diag = DiagnosticCollector::new();
1212 let merged = merge_model_config(&IndexMap::new(), &[], &mut diag);
1213 assert!(merged.contains_key("opus"));
1215 assert!(merged.contains_key("sonnet"));
1216 assert!(merged.contains_key("codex"));
1217 }
1218
1219 #[test]
1220 fn merge_consumer_overrides_dependency_alias() {
1221 let mut consumer = IndexMap::new();
1222 consumer.insert(
1223 "opus".to_string(),
1224 pinned_alias(Some("custom"), "my-opus-model"),
1225 );
1226
1227 let mut diag = DiagnosticCollector::new();
1228 let merged = merge_model_config(&consumer, &[], &mut diag);
1229 assert_eq!(
1230 merged.get("opus").unwrap().spec,
1231 ModelSpec::Pinned {
1232 model: "my-opus-model".to_string(),
1233 provider: None
1234 }
1235 );
1236 }
1237
1238 #[test]
1239 fn merge_dep_overrides_builtin() {
1240 let dep = ResolvedDepModels {
1241 source_name: "my-pkg".to_string(),
1242 models: {
1243 let mut m = IndexMap::new();
1244 m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
1245 m
1246 },
1247 };
1248
1249 let mut diag = DiagnosticCollector::new();
1250 let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag);
1251 assert_eq!(
1253 merged.get("opus").unwrap().spec,
1254 ModelSpec::Pinned {
1255 model: "pkg-opus".to_string(),
1256 provider: None
1257 }
1258 );
1259 }
1260
1261 #[test]
1262 fn merge_consumer_beats_dep() {
1263 let mut consumer = IndexMap::new();
1264 consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
1265
1266 let dep = ResolvedDepModels {
1267 source_name: "pkg".to_string(),
1268 models: {
1269 let mut m = IndexMap::new();
1270 m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
1271 m
1272 },
1273 };
1274
1275 let mut diag = DiagnosticCollector::new();
1276 let merged = merge_model_config(&consumer, &[dep], &mut diag);
1277 assert_eq!(
1278 merged.get("opus").unwrap().spec,
1279 ModelSpec::Pinned {
1280 model: "consumer-opus".to_string(),
1281 provider: None
1282 }
1283 );
1284 }
1285
1286 #[test]
1287 fn merge_dep_conflict_warns() {
1288 let dep1 = ResolvedDepModels {
1289 source_name: "pkg-a".to_string(),
1290 models: {
1291 let mut m = IndexMap::new();
1292 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1293 m
1294 },
1295 };
1296 let dep2 = ResolvedDepModels {
1297 source_name: "pkg-b".to_string(),
1298 models: {
1299 let mut m = IndexMap::new();
1300 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1301 m
1302 },
1303 };
1304
1305 let mut diag = DiagnosticCollector::new();
1306 let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1307 assert_eq!(
1309 merged.get("custom").unwrap().spec,
1310 ModelSpec::Pinned {
1311 model: "model-a".to_string(),
1312 provider: None
1313 }
1314 );
1315 let warnings = diag.drain();
1317 assert_eq!(warnings.len(), 1);
1318 assert_eq!(warnings[0].code, "model-alias-conflict");
1319 }
1320
1321 #[test]
1324 fn resolve_all_pinned() {
1325 let mut aliases = IndexMap::new();
1326 aliases.insert(
1327 "fast".to_string(),
1328 pinned_alias(Some("claude"), "claude-haiku-4-5"),
1329 );
1330
1331 let cache = ModelsCache {
1332 models: Vec::new(),
1333 fetched_at: None,
1334 };
1335
1336 let resolved = resolve_all(&aliases, &cache);
1337 let entry = resolved.get("fast").unwrap();
1338 assert_eq!(entry.model_id, "claude-haiku-4-5");
1339 assert_eq!(entry.provider, "anthropic");
1340 }
1341
1342 #[test]
1343 fn resolve_all_pinned_with_provider() {
1344 let mut aliases = IndexMap::new();
1345 aliases.insert(
1346 "fast".to_string(),
1347 ModelAlias {
1348 harness: None,
1349 description: None,
1350 spec: ModelSpec::Pinned {
1351 model: "gpt-5.3-codex".to_string(),
1352 provider: Some("openai".to_string()),
1353 },
1354 },
1355 );
1356
1357 let cache = ModelsCache {
1358 models: Vec::new(),
1359 fetched_at: None,
1360 };
1361
1362 let resolved = resolve_all(&aliases, &cache);
1363 let entry = resolved.get("fast").unwrap();
1364 assert_eq!(entry.model_id, "gpt-5.3-codex");
1365 assert_eq!(entry.provider, "openai");
1366 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1367 }
1368
1369 #[test]
1370 fn resolve_all_pinned_auto_detect_harness() {
1371 let mut aliases = IndexMap::new();
1372 aliases.insert(
1373 "opus".to_string(),
1374 ModelAlias {
1375 harness: None,
1376 description: None,
1377 spec: ModelSpec::Pinned {
1378 model: "claude-opus-4-6".to_string(),
1379 provider: Some("anthropic".to_string()),
1380 },
1381 },
1382 );
1383
1384 let cache = ModelsCache {
1385 models: Vec::new(),
1386 fetched_at: None,
1387 };
1388
1389 let resolved = resolve_all(&aliases, &cache);
1390 let entry = resolved.get("opus").unwrap();
1391 assert_eq!(entry.model_id, "claude-opus-4-6");
1392 assert_eq!(entry.provider, "anthropic");
1393
1394 let installed = harness::detect_installed_harnesses();
1395 let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1396 let expected_source = if expected_harness.is_some() {
1397 HarnessSource::AutoDetected
1398 } else {
1399 HarnessSource::Unavailable
1400 };
1401
1402 assert_eq!(entry.harness, expected_harness);
1403 assert_eq!(entry.harness_source, expected_source);
1404 }
1405
1406 #[test]
1407 fn resolve_all_auto_detect_harness() {
1408 let mut aliases = IndexMap::new();
1409 aliases.insert(
1410 "gpt".to_string(),
1411 ModelAlias {
1412 harness: None,
1413 description: None,
1414 spec: ModelSpec::AutoResolve {
1415 provider: "openai".to_string(),
1416 match_patterns: vec!["gpt-5*".to_string()],
1417 exclude_patterns: vec![],
1418 },
1419 },
1420 );
1421 let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
1422
1423 let resolved = resolve_all(&aliases, &cache);
1424 let entry = resolved.get("gpt").unwrap();
1425 assert_eq!(entry.model_id, "gpt-5");
1426 assert_eq!(entry.provider, "openai");
1427 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1428 match entry.harness_source {
1429 HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
1430 HarnessSource::Unavailable => assert!(entry.harness.is_none()),
1431 HarnessSource::Explicit => panic!("unexpected explicit harness source"),
1432 }
1433 }
1434
1435 #[test]
1436 fn resolve_all_unavailable_harness_still_included() {
1437 let mut aliases = IndexMap::new();
1438 aliases.insert(
1439 "opus".to_string(),
1440 ModelAlias {
1441 harness: Some("missing-harness-xyz".to_string()),
1442 description: None,
1443 spec: ModelSpec::Pinned {
1444 model: "claude-opus-4-6".to_string(),
1445 provider: None,
1446 },
1447 },
1448 );
1449
1450 let cache = ModelsCache {
1451 models: Vec::new(),
1452 fetched_at: None,
1453 };
1454
1455 let resolved = resolve_all(&aliases, &cache);
1456 let entry = resolved.get("opus").unwrap();
1457 assert_eq!(entry.model_id, "claude-opus-4-6");
1458 assert_eq!(entry.provider, "anthropic");
1459 assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
1460 assert_eq!(entry.harness_source, HarnessSource::Unavailable);
1461 }
1462
1463 #[test]
1464 fn resolve_all_empty_cache_omits_unresolvable() {
1465 let mut aliases = IndexMap::new();
1466 aliases.insert(
1467 "opus".to_string(),
1468 ModelAlias {
1469 harness: Some("claude".to_string()),
1470 description: None,
1471 spec: ModelSpec::AutoResolve {
1472 provider: "Anthropic".to_string(),
1473 match_patterns: vec!["claude-opus-*".to_string()],
1474 exclude_patterns: vec![],
1475 },
1476 },
1477 );
1478 let cache = ModelsCache {
1479 models: Vec::new(),
1480 fetched_at: None,
1481 };
1482
1483 let resolved = resolve_all(&aliases, &cache);
1484 assert!(!resolved.contains_key("opus"));
1486 }
1487
1488 fn make_resolved_alias(name: &str) -> ResolvedAlias {
1489 ResolvedAlias {
1490 name: name.to_string(),
1491 model_id: format!("model-{name}"),
1492 provider: "openai".to_string(),
1493 harness: Some("codex".to_string()),
1494 harness_source: HarnessSource::Explicit,
1495 harness_candidates: vec!["codex".to_string()],
1496 description: None,
1497 }
1498 }
1499
1500 #[test]
1501 fn filter_by_visibility_include_mode_keeps_matches_only() {
1502 let mut aliases = IndexMap::new();
1503 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1504 aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1505 aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
1506
1507 let filtered = filter_by_visibility(
1508 aliases,
1509 &crate::config::ModelVisibility {
1510 include: Some(vec!["opus*".to_string(), "gpt-*".to_string()]),
1511 exclude: None,
1512 },
1513 );
1514
1515 assert_eq!(filtered.len(), 2);
1516 assert!(filtered.contains_key("opus"));
1517 assert!(filtered.contains_key("gpt-5"));
1518 assert!(!filtered.contains_key("sonnet"));
1519 }
1520
1521 #[test]
1522 fn filter_by_visibility_exclude_mode_removes_matches() {
1523 let mut aliases = IndexMap::new();
1524 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1525 aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
1526 aliases.insert(
1527 "deprecated-gpt".to_string(),
1528 make_resolved_alias("deprecated-gpt"),
1529 );
1530
1531 let filtered = filter_by_visibility(
1532 aliases,
1533 &crate::config::ModelVisibility {
1534 include: None,
1535 exclude: Some(vec!["test-*".to_string(), "deprecated-*".to_string()]),
1536 },
1537 );
1538
1539 assert_eq!(filtered.len(), 1);
1540 assert!(filtered.contains_key("opus"));
1541 assert!(!filtered.contains_key("test-opus"));
1542 assert!(!filtered.contains_key("deprecated-gpt"));
1543 }
1544
1545 #[test]
1546 fn filter_by_visibility_empty_config_returns_all() {
1547 let mut aliases = IndexMap::new();
1548 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1549 aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1550 let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
1551 assert_eq!(filtered.len(), 2);
1552 assert!(filtered.contains_key("opus"));
1553 assert!(filtered.contains_key("sonnet"));
1554 }
1555
1556 #[test]
1557 fn resolve_model_and_provider_pinned_explicit_provider() {
1558 let alias = ModelAlias {
1559 harness: None,
1560 description: None,
1561 spec: ModelSpec::Pinned {
1562 model: "claude-opus-4-6".to_string(),
1563 provider: Some("anthropic".to_string()),
1564 },
1565 };
1566 let cache = ModelsCache {
1567 models: Vec::new(),
1568 fetched_at: None,
1569 };
1570
1571 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1572 assert_eq!(
1573 resolved,
1574 ("claude-opus-4-6".to_string(), "anthropic".to_string())
1575 );
1576 }
1577
1578 #[test]
1579 fn resolve_model_and_provider_pinned_inferred() {
1580 let alias = ModelAlias {
1581 harness: None,
1582 description: None,
1583 spec: ModelSpec::Pinned {
1584 model: "claude-opus-4-6".to_string(),
1585 provider: None,
1586 },
1587 };
1588 let cache = ModelsCache {
1589 models: Vec::new(),
1590 fetched_at: None,
1591 };
1592
1593 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1594 assert_eq!(
1595 resolved,
1596 ("claude-opus-4-6".to_string(), "anthropic".to_string())
1597 );
1598 }
1599
1600 #[test]
1601 fn resolve_model_and_provider_pinned_unknown() {
1602 let alias = ModelAlias {
1603 harness: None,
1604 description: None,
1605 spec: ModelSpec::Pinned {
1606 model: "my-custom-model".to_string(),
1607 provider: None,
1608 },
1609 };
1610 let cache = ModelsCache {
1611 models: Vec::new(),
1612 fetched_at: None,
1613 };
1614
1615 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1616 assert_eq!(
1617 resolved,
1618 ("my-custom-model".to_string(), "unknown".to_string())
1619 );
1620 }
1621
1622 #[test]
1623 fn resolve_model_and_provider_auto_resolve() {
1624 let alias = ModelAlias {
1625 harness: None,
1626 description: None,
1627 spec: ModelSpec::AutoResolve {
1628 provider: "openai".to_string(),
1629 match_patterns: vec!["gpt-5*".to_string()],
1630 exclude_patterns: vec![],
1631 },
1632 };
1633 let cache = make_cache(vec![
1634 ("gpt-4o", "OpenAI", Some("2024-06-01")),
1635 ("gpt-5", "OpenAI", Some("2025-06-01")),
1636 ]);
1637
1638 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1639 assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
1640 }
1641
1642 #[test]
1643 fn resolve_harness_explicit_installed() {
1644 let alias = ModelAlias {
1645 harness: Some("claude".to_string()),
1646 description: None,
1647 spec: ModelSpec::Pinned {
1648 model: "claude-opus-4-6".to_string(),
1649 provider: None,
1650 },
1651 };
1652 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1653
1654 let resolved = resolve_harness(&alias, "anthropic", &installed);
1655 assert_eq!(
1656 resolved,
1657 (Some("claude".to_string()), HarnessSource::Explicit)
1658 );
1659 }
1660
1661 #[test]
1662 fn resolve_harness_explicit_not_installed() {
1663 let alias = ModelAlias {
1664 harness: Some("claude".to_string()),
1665 description: None,
1666 spec: ModelSpec::Pinned {
1667 model: "claude-opus-4-6".to_string(),
1668 provider: None,
1669 },
1670 };
1671 let installed = HashSet::new();
1672
1673 let resolved = resolve_harness(&alias, "anthropic", &installed);
1674 assert_eq!(
1675 resolved,
1676 (Some("claude".to_string()), HarnessSource::Unavailable)
1677 );
1678 }
1679
1680 #[test]
1681 fn resolve_harness_auto_detected() {
1682 let alias = ModelAlias {
1683 harness: None,
1684 description: None,
1685 spec: ModelSpec::Pinned {
1686 model: "claude-opus-4-6".to_string(),
1687 provider: Some("anthropic".to_string()),
1688 },
1689 };
1690 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1691
1692 let resolved = resolve_harness(&alias, "anthropic", &installed);
1693 assert_eq!(
1694 resolved,
1695 (Some("claude".to_string()), HarnessSource::AutoDetected)
1696 );
1697 }
1698
1699 #[test]
1700 fn resolve_harness_unavailable() {
1701 let alias = ModelAlias {
1702 harness: None,
1703 description: None,
1704 spec: ModelSpec::Pinned {
1705 model: "claude-opus-4-6".to_string(),
1706 provider: Some("anthropic".to_string()),
1707 },
1708 };
1709 let installed = HashSet::new();
1710
1711 let resolved = resolve_harness(&alias, "anthropic", &installed);
1712 assert_eq!(resolved, (None, HarnessSource::Unavailable));
1713 }
1714
1715 #[test]
1716 fn resolve_harness_unavailable_no_provider_match() {
1717 let alias = ModelAlias {
1718 harness: None,
1719 description: None,
1720 spec: ModelSpec::Pinned {
1721 model: "my-custom-model".to_string(),
1722 provider: Some("unknown".to_string()),
1723 },
1724 };
1725 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1726
1727 let resolved = resolve_harness(&alias, "unknown", &installed);
1728 assert_eq!(resolved, (None, HarnessSource::Unavailable));
1729 }
1730
1731 #[test]
1734 fn harness_source_serializes_snake_case() {
1735 assert_eq!(
1736 serde_json::to_string(&HarnessSource::Explicit).unwrap(),
1737 "\"explicit\""
1738 );
1739 assert_eq!(
1740 serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
1741 "\"auto_detected\""
1742 );
1743 assert_eq!(
1744 serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
1745 "\"unavailable\""
1746 );
1747 }
1748
1749 #[test]
1750 fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
1751 let toml_str = r#"
1752[models.fast]
1753harness = "claude"
1754model = "claude-haiku-4-5"
1755description = "Fast and cheap"
1756"#;
1757
1758 #[derive(Debug, Deserialize)]
1759 struct Wrapper {
1760 models: IndexMap<String, ModelAlias>,
1761 }
1762
1763 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1764 let alias = parsed.models.get("fast").unwrap();
1765 assert_eq!(
1766 alias.spec,
1767 ModelSpec::Pinned {
1768 model: "claude-haiku-4-5".to_string(),
1769 provider: None
1770 }
1771 );
1772 assert_eq!(alias.harness.as_deref(), Some("claude"));
1773 assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
1774
1775 let json = serde_json::to_string(alias).unwrap();
1776 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1777 assert_eq!(roundtripped, *alias);
1778 }
1779
1780 #[test]
1781 fn model_alias_pinned_toml_roundtrip_without_harness() {
1782 let toml_str = r#"
1783[models.fast]
1784model = "claude-haiku-4-5"
1785"#;
1786
1787 #[derive(Debug, Deserialize)]
1788 struct Wrapper {
1789 models: IndexMap<String, ModelAlias>,
1790 }
1791
1792 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1793 let alias = parsed.models.get("fast").unwrap();
1794 assert_eq!(alias.harness, None);
1795 assert_eq!(
1796 alias.spec,
1797 ModelSpec::Pinned {
1798 model: "claude-haiku-4-5".to_string(),
1799 provider: None
1800 }
1801 );
1802
1803 let json = serde_json::to_string(alias).unwrap();
1804 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1805 assert!(value.get("harness").is_none());
1806 assert!(value.get("provider").is_none());
1807 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1808 assert_eq!(roundtripped, *alias);
1809 }
1810
1811 #[test]
1812 fn model_alias_pinned_toml_roundtrip_with_provider() {
1813 let toml_str = r#"
1814[models.fast]
1815model = "claude-haiku-4-5"
1816provider = "anthropic"
1817"#;
1818
1819 #[derive(Debug, Deserialize)]
1820 struct Wrapper {
1821 models: IndexMap<String, ModelAlias>,
1822 }
1823
1824 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1825 let alias = parsed.models.get("fast").unwrap();
1826 assert_eq!(alias.harness, None);
1827 assert_eq!(
1828 alias.spec,
1829 ModelSpec::Pinned {
1830 model: "claude-haiku-4-5".to_string(),
1831 provider: Some("anthropic".to_string())
1832 }
1833 );
1834
1835 let json = serde_json::to_string(alias).unwrap();
1836 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1837 assert_eq!(
1838 value.get("provider").and_then(serde_json::Value::as_str),
1839 Some("anthropic")
1840 );
1841 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1842 assert_eq!(roundtripped, *alias);
1843 }
1844
1845 #[test]
1846 fn model_alias_pinned_json_roundtrip_with_provider() {
1847 let json = r#"{
1848 "model": "gpt-5.3-codex",
1849 "provider": "openai"
1850 }"#;
1851
1852 let alias: ModelAlias = serde_json::from_str(json).unwrap();
1853 assert_eq!(alias.harness, None);
1854 assert_eq!(alias.description, None);
1855 assert_eq!(
1856 alias.spec,
1857 ModelSpec::Pinned {
1858 model: "gpt-5.3-codex".to_string(),
1859 provider: Some("openai".to_string())
1860 }
1861 );
1862
1863 let encoded = serde_json::to_string(&alias).unwrap();
1864 let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
1865 assert_eq!(roundtripped, alias);
1866 }
1867
1868 #[test]
1869 fn model_alias_auto_resolve_toml_roundtrip() {
1870 let toml_str = r#"
1871[models.opus]
1872harness = "claude"
1873provider = "Anthropic"
1874match = ["claude-opus-*"]
1875exclude = ["claude-opus-3*"]
1876description = "Best reasoning"
1877"#;
1878
1879 #[derive(Debug, Deserialize)]
1880 struct Wrapper {
1881 models: IndexMap<String, ModelAlias>,
1882 }
1883
1884 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1885 let alias = parsed.models.get("opus").unwrap();
1886 assert_eq!(alias.harness.as_deref(), Some("claude"));
1887 match &alias.spec {
1888 ModelSpec::AutoResolve {
1889 provider,
1890 match_patterns,
1891 exclude_patterns,
1892 } => {
1893 assert_eq!(provider, "Anthropic");
1894 assert_eq!(match_patterns, &["claude-opus-*"]);
1895 assert_eq!(exclude_patterns, &["claude-opus-3*"]);
1896 }
1897 _ => panic!("expected AutoResolve"),
1898 }
1899 }
1900
1901 #[test]
1902 fn model_alias_both_model_and_match_errors() {
1903 let toml_str = r#"
1904[models.bad]
1905harness = "claude"
1906model = "some-model"
1907match = ["pattern-*"]
1908"#;
1909
1910 #[derive(Debug, Deserialize)]
1911 struct Wrapper {
1912 #[expect(dead_code)]
1913 models: IndexMap<String, ModelAlias>,
1914 }
1915
1916 let result = toml::from_str::<Wrapper>(toml_str);
1917 assert!(result.is_err());
1918 let err_msg = result.unwrap_err().to_string();
1919 assert!(err_msg.contains("both"));
1920 }
1921
1922 #[test]
1923 fn model_alias_neither_model_nor_match_errors() {
1924 let toml_str = r#"
1925[models.bad]
1926harness = "claude"
1927"#;
1928
1929 #[derive(Debug, Deserialize)]
1930 struct Wrapper {
1931 #[expect(dead_code)]
1932 models: IndexMap<String, ModelAlias>,
1933 }
1934
1935 let result = toml::from_str::<Wrapper>(toml_str);
1936 assert!(result.is_err());
1937 }
1938
1939 #[test]
1940 fn infer_provider_from_model_id_detects_known_prefixes() {
1941 assert_eq!(
1942 infer_provider_from_model_id("claude-opus-4-6"),
1943 Some("anthropic")
1944 );
1945 assert_eq!(
1946 infer_provider_from_model_id("gpt-5.3-codex"),
1947 Some("openai")
1948 );
1949 assert_eq!(
1950 infer_provider_from_model_id("gemini-2.5-pro"),
1951 Some("google")
1952 );
1953 assert_eq!(
1954 infer_provider_from_model_id("llama-4-maverick"),
1955 Some("meta")
1956 );
1957 assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
1958 assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
1959 assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
1960 assert_eq!(
1961 infer_provider_from_model_id("codex-mini-latest"),
1962 Some("openai")
1963 );
1964 assert_eq!(
1965 infer_provider_from_model_id("mistral-large"),
1966 Some("mistral")
1967 );
1968 assert_eq!(
1969 infer_provider_from_model_id("codestral-latest"),
1970 Some("mistral")
1971 );
1972 assert_eq!(
1973 infer_provider_from_model_id("deepseek-chat"),
1974 Some("deepseek")
1975 );
1976 assert_eq!(
1977 infer_provider_from_model_id("command-r-plus"),
1978 Some("cohere")
1979 );
1980 }
1981
1982 #[test]
1983 fn infer_provider_from_model_id_returns_none_for_unknown_model() {
1984 assert_eq!(infer_provider_from_model_id("unknown-model"), None);
1985 }
1986
1987 #[test]
1988 fn infer_provider_from_model_id_returns_none_for_empty_string() {
1989 assert_eq!(infer_provider_from_model_id(""), None);
1990 }
1991
1992 #[test]
1993 fn infer_provider_from_model_id_is_case_insensitive() {
1994 assert_eq!(
1995 infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
1996 Some("anthropic")
1997 );
1998 assert_eq!(
1999 infer_provider_from_model_id("GPT-5.3-codex"),
2000 Some("openai")
2001 );
2002 assert_eq!(
2003 infer_provider_from_model_id("CoDeStRaL-latest"),
2004 Some("mistral")
2005 );
2006 }
2007
2008 #[allow(unused_unsafe)]
2009 fn env_set(key: &str, value: &str) {
2010 unsafe {
2011 std::env::set_var(key, value);
2012 }
2013 }
2014
2015 #[allow(unused_unsafe)]
2016 fn env_remove(key: &str) {
2017 unsafe {
2018 std::env::remove_var(key);
2019 }
2020 }
2021
2022 struct EnvVarGuard {
2023 key: String,
2024 prev: Option<String>,
2025 }
2026
2027 impl EnvVarGuard {
2028 fn set(key: &str, value: &str) -> Self {
2029 let prev = std::env::var(key).ok();
2030 env_set(key, value);
2031 Self {
2032 key: key.to_string(),
2033 prev,
2034 }
2035 }
2036 }
2037
2038 impl Drop for EnvVarGuard {
2039 fn drop(&mut self) {
2040 if let Some(prev) = &self.prev {
2041 env_set(&self.key, prev);
2042 } else {
2043 env_remove(&self.key);
2044 }
2045 }
2046 }
2047
2048 fn sample_catalog_json() -> serde_json::Value {
2049 serde_json::json!({
2050 "openai": {
2051 "models": {
2052 "gpt-5": {
2053 "id": "gpt-5",
2054 "name": "GPT-5",
2055 "release_date": "2025-06-01",
2056 "limit": {
2057 "context": 400000,
2058 "output": 128000
2059 }
2060 }
2061 }
2062 },
2063 "anthropic": {
2064 "models": {
2065 "claude-sonnet-4-5": {
2066 "id": "claude-sonnet-4-5",
2067 "name": "Claude Sonnet 4.5",
2068 "release_date": "2025-03-01"
2069 }
2070 }
2071 }
2072 })
2073 }
2074
2075 fn sample_cached_model(id: &str) -> CachedModel {
2076 CachedModel {
2077 id: id.to_string(),
2078 provider: "OpenAI".to_string(),
2079 release_date: None,
2080 description: None,
2081 context_window: None,
2082 max_output: None,
2083 }
2084 }
2085
2086 fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
2087 write_cache(
2088 mars_dir,
2089 &ModelsCache {
2090 models,
2091 fetched_at: Some(fetched_at.to_string()),
2092 },
2093 )
2094 .expect("failed to write cache fixture");
2095 }
2096
2097 fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
2098 std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
2099 std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
2100 }
2101
2102 fn stale_timestamp() -> String {
2103 now_unix_secs_value().saturating_sub(48 * 3600).to_string()
2104 }
2105
2106 fn fresh_timestamp() -> String {
2107 now_unix_secs_value().saturating_sub(60).to_string()
2108 }
2109
2110 fn assert_model_cache_unavailable(
2111 result: Result<(ModelsCache, RefreshOutcome), MarsError>,
2112 reason_contains: &str,
2113 ) {
2114 match result {
2115 Err(MarsError::ModelCacheUnavailable { reason }) => {
2116 assert!(
2117 reason.contains(reason_contains),
2118 "unexpected reason: {reason}"
2119 );
2120 }
2121 other => panic!("expected ModelCacheUnavailable, got {other:?}"),
2122 }
2123 }
2124
2125 #[test]
2126 #[serial]
2127 fn ensure_fresh_1_missing_cache_offline_errors() {
2128 let mars = tempdir().unwrap();
2129 let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2130
2131 let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2132 assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
2133 }
2134
2135 #[test]
2136 #[serial]
2137 fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
2138 let mars = tempdir().unwrap();
2139 let server = MockServer::start();
2140 let mock = server.mock(|when, then| {
2141 when.method(GET).path("/api.json");
2142 then.status(500).body("server error");
2143 });
2144 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2145
2146 let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2147 assert_model_cache_unavailable(result, "automatic refresh failed");
2148 assert_eq!(mock.hits(), 1);
2149 }
2150
2151 #[test]
2152 fn ensure_fresh_3_stale_usable_offline_returns_stale() {
2153 let mars = tempdir().unwrap();
2154 write_cache_state(
2155 mars.path(),
2156 vec![sample_cached_model("stale-model")],
2157 &stale_timestamp(),
2158 );
2159
2160 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
2161 assert_eq!(cache.models.len(), 1);
2162 assert_eq!(cache.models[0].id, "stale-model");
2163 assert_eq!(outcome, RefreshOutcome::Offline);
2164 }
2165
2166 #[test]
2167 #[serial]
2168 fn ensure_fresh_4_fresh_auto_skips_http() {
2169 let mars = tempdir().unwrap();
2170 write_cache_state(
2171 mars.path(),
2172 vec![sample_cached_model("fresh-model")],
2173 &fresh_timestamp(),
2174 );
2175
2176 let server = MockServer::start();
2177 let mock = server.mock(|when, then| {
2178 when.method(GET).path("/api.json");
2179 then.status(200).json_body(sample_catalog_json());
2180 });
2181 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2182
2183 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2184 assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
2185 assert_eq!(mock.hits(), 0);
2186 }
2187
2188 #[test]
2189 #[serial]
2190 fn ensure_fresh_5_stale_auto_success_refreshes() {
2191 let mars = tempdir().unwrap();
2192 write_cache_state(
2193 mars.path(),
2194 vec![sample_cached_model("old-model")],
2195 &stale_timestamp(),
2196 );
2197
2198 let server = MockServer::start();
2199 let mock = server.mock(|when, then| {
2200 when.method(GET).path("/api.json");
2201 then.status(200).json_body(sample_catalog_json());
2202 });
2203 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2204
2205 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2206 assert!(matches!(
2207 outcome,
2208 RefreshOutcome::Refreshed { models_count } if models_count == 2
2209 ));
2210 assert_eq!(cache.models.len(), 2);
2211 assert!(!cache.models.is_empty());
2212 assert!(cache.fetched_at.is_some());
2213 assert_eq!(mock.hits(), 1);
2214 }
2215
2216 #[test]
2217 #[serial]
2218 fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
2219 let mars = tempdir().unwrap();
2220 write_cache_state(
2221 mars.path(),
2222 vec![sample_cached_model("stale-model")],
2223 &stale_timestamp(),
2224 );
2225
2226 let server = MockServer::start();
2227 let mock = server.mock(|when, then| {
2228 when.method(GET).path("/api.json");
2229 then.status(500).body("server error");
2230 });
2231 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2232
2233 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2234 assert_eq!(cache.models[0].id, "stale-model");
2235 assert!(matches!(
2236 outcome,
2237 RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2238 ));
2239 assert_eq!(mock.hits(), 1);
2240 }
2241
2242 #[test]
2243 #[serial]
2244 fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
2245 let mars = tempdir().unwrap();
2246 write_cache_state(
2247 mars.path(),
2248 vec![sample_cached_model("stale-model")],
2249 &stale_timestamp(),
2250 );
2251
2252 let server = MockServer::start();
2253 let mock = server.mock(|when, then| {
2254 when.method(GET).path("/api.json");
2255 then.status(200).json_body(serde_json::json!({}));
2256 });
2257 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2258
2259 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2260 assert_eq!(cache.models[0].id, "stale-model");
2261 assert!(matches!(
2262 outcome,
2263 RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
2264 ));
2265 assert_eq!(mock.hits(), 1);
2266 }
2267
2268 #[test]
2269 #[serial]
2270 fn ensure_fresh_8_empty_cache_auto_refetches() {
2271 let mars = tempdir().unwrap();
2272 write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2273
2274 let server = MockServer::start();
2275 let mock = server.mock(|when, then| {
2276 when.method(GET).path("/api.json");
2277 then.status(200).json_body(sample_catalog_json());
2278 });
2279 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2280
2281 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2282 assert!(!cache.models.is_empty());
2283 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2284 assert_eq!(mock.hits(), 1);
2285 }
2286
2287 #[test]
2288 fn ensure_fresh_9_empty_cache_offline_errors() {
2289 let mars = tempdir().unwrap();
2290 write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2291
2292 let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2293 assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2294 }
2295
2296 #[test]
2297 #[serial]
2298 fn ensure_fresh_10_corrupt_json_auto_refetches() {
2299 let mars = tempdir().unwrap();
2300 write_raw_cache_file(mars.path(), "{ not-json ");
2301
2302 let server = MockServer::start();
2303 let mock = server.mock(|when, then| {
2304 when.method(GET).path("/api.json");
2305 then.status(200).json_body(sample_catalog_json());
2306 });
2307 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2308
2309 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2310 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2311 assert!(!cache.models.is_empty());
2312 assert_eq!(mock.hits(), 1);
2313 }
2314
2315 #[test]
2316 fn ensure_fresh_11_corrupt_json_offline_errors() {
2317 let mars = tempdir().unwrap();
2318 write_raw_cache_file(mars.path(), "{ not-json ");
2319
2320 let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2321 assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2322 }
2323
2324 #[test]
2325 #[serial]
2326 fn ensure_fresh_12_ttl_zero_always_refetches() {
2327 let mars = tempdir().unwrap();
2328 write_cache_state(
2329 mars.path(),
2330 vec![sample_cached_model("fresh-model")],
2331 &fresh_timestamp(),
2332 );
2333
2334 let server = MockServer::start();
2335 let mock = server.mock(|when, then| {
2336 when.method(GET).path("/api.json");
2337 then.status(200).json_body(sample_catalog_json());
2338 });
2339 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2340
2341 let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
2342 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2343 assert_eq!(mock.hits(), 1);
2344 }
2345
2346 #[test]
2347 #[serial]
2348 fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
2349 let mars = tempdir().unwrap();
2350 write_cache_state(
2351 mars.path(),
2352 vec![sample_cached_model("stale-model")],
2353 "not-a-timestamp",
2354 );
2355
2356 let server = MockServer::start();
2357 let mock = server.mock(|when, then| {
2358 when.method(GET).path("/api.json");
2359 then.status(200).json_body(sample_catalog_json());
2360 });
2361 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2362
2363 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2364 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2365 assert_eq!(mock.hits(), 1);
2366 }
2367
2368 #[test]
2369 #[serial]
2370 fn ensure_fresh_14_future_fetched_at_is_stale() {
2371 let mars = tempdir().unwrap();
2372 let future = now_unix_secs_value() + 3600;
2373 write_cache_state(
2374 mars.path(),
2375 vec![sample_cached_model("future-model")],
2376 &future.to_string(),
2377 );
2378
2379 let server = MockServer::start();
2380 let mock = server.mock(|when, then| {
2381 when.method(GET).path("/api.json");
2382 then.status(200).json_body(sample_catalog_json());
2383 });
2384 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2385
2386 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2387 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2388 assert_eq!(mock.hits(), 1);
2389 }
2390
2391 #[test]
2392 #[serial]
2393 fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
2394 let mars = tempdir().unwrap();
2395 write_cache_state(
2396 mars.path(),
2397 vec![sample_cached_model("fresh-model")],
2398 &fresh_timestamp(),
2399 );
2400
2401 let server = MockServer::start();
2402 let mock = server.mock(|when, then| {
2403 when.method(GET).path("/api.json");
2404 then.status(200).json_body(sample_catalog_json());
2405 });
2406 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2407 let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2408
2409 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2410 assert_eq!(outcome, RefreshOutcome::Offline);
2411 assert_eq!(mock.hits(), 0);
2412 }
2413
2414 #[test]
2415 #[serial]
2416 fn ensure_fresh_16_offline_env_zero_is_not_offline() {
2417 let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
2418 assert!(!is_mars_offline());
2419 assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2420 }
2421
2422 #[test]
2423 #[serial]
2424 fn ensure_fresh_17_offline_env_truthy_is_offline() {
2425 let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
2426 assert!(is_mars_offline());
2427 assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2428 }
2429
2430 #[test]
2431 #[serial]
2432 fn ensure_fresh_18_force_ignores_offline_env() {
2433 let mars = tempdir().unwrap();
2434 let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2435
2436 let server = MockServer::start();
2437 let mock = server.mock(|when, then| {
2438 when.method(GET).path("/api.json");
2439 then.status(200).json_body(sample_catalog_json());
2440 });
2441 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2442
2443 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
2444 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2445 assert_eq!(mock.hits(), 1);
2446 }
2447
2448 #[test]
2449 #[serial]
2450 fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
2451 let mars = tempdir().unwrap();
2452 write_cache_state(
2453 mars.path(),
2454 vec![sample_cached_model("stale-model")],
2455 &stale_timestamp(),
2456 );
2457
2458 let path = Arc::new(mars.path().to_path_buf());
2459 let path_a = Arc::clone(&path);
2460 let path_b = Arc::clone(&path);
2461 let fetch_hits = Arc::new(AtomicUsize::new(0));
2462 let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
2463 let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
2464
2465 let fetch_hits_a = Arc::clone(&fetch_hits);
2466 let t1 = thread::spawn(move || {
2467 ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
2468 fetch_hits_a.fetch_add(1, Ordering::SeqCst);
2469 fetch_started_tx.send(()).unwrap();
2470 release_fetch_rx.recv().unwrap();
2471 Ok(vec![sample_cached_model("fresh-model")])
2472 })
2473 .unwrap()
2474 .1
2475 });
2476
2477 fetch_started_rx.recv().unwrap();
2478
2479 let fetch_hits_b = Arc::clone(&fetch_hits);
2480 let t2 = thread::spawn(move || {
2481 ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
2482 fetch_hits_b.fetch_add(1, Ordering::SeqCst);
2483 Ok(vec![sample_cached_model("unexpected-second-refresh")])
2484 })
2485 .unwrap()
2486 .1
2487 });
2488
2489 release_fetch_tx.send(()).unwrap();
2490
2491 let outcome_a = t1.join().unwrap();
2492 let outcome_b = t2.join().unwrap();
2493
2494 let outcomes = [outcome_a, outcome_b];
2495 let refreshed = outcomes
2496 .iter()
2497 .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
2498 .count();
2499 let already_fresh = outcomes
2500 .iter()
2501 .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
2502 .count();
2503
2504 assert_eq!(refreshed, 1);
2505 assert_eq!(already_fresh, 1);
2506 assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
2507 }
2508
2509 #[test]
2510 #[serial]
2511 fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
2512 let mars = tempdir().unwrap();
2513 write_cache_state(
2514 mars.path(),
2515 vec![sample_cached_model("stale-model")],
2516 &stale_timestamp(),
2517 );
2518
2519 let server = MockServer::start();
2520 let mock = server.mock(|when, then| {
2521 when.method(GET).path("/api.json");
2522 then.status(500).body("server error");
2523 });
2524 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2525
2526 let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2527 let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2528
2529 assert!(matches!(
2530 outcome_a,
2531 RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2532 ));
2533 assert_eq!(
2534 outcome_b,
2535 RefreshOutcome::StaleFallback {
2536 reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2537 }
2538 );
2539 assert_eq!(mock.hits(), 1);
2540 }
2541
2542 #[test]
2543 #[serial]
2544 fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
2545 let mars = tempdir().unwrap();
2546 write_cache_state(
2547 mars.path(),
2548 vec![sample_cached_model("stale-model")],
2549 &stale_timestamp(),
2550 );
2551
2552 let server = MockServer::start();
2553 let mock = server.mock(|when, then| {
2554 when.method(GET).path("/api.json");
2555 then.status(200).json_body(serde_json::json!({
2556 "openai": {
2557 "models": {}
2558 }
2559 }));
2560 });
2561 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2562
2563 let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2564 let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2565
2566 assert!(matches!(
2567 outcome_a,
2568 RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
2569 ));
2570 assert_eq!(
2571 outcome_b,
2572 RefreshOutcome::StaleFallback {
2573 reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2574 }
2575 );
2576 assert_eq!(mock.hits(), 1);
2577 }
2578
2579 #[test]
2580 fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
2581 let project = tempdir().unwrap();
2582 let ctx = crate::types::MarsContext::for_test(
2583 project.path().to_path_buf(),
2584 project.path().join(".agents"),
2585 );
2586 assert_eq!(load_models_cache_ttl(&ctx), 24);
2587 }
2588
2589 #[test]
2590 fn load_models_cache_ttl_reads_config_value() {
2591 let project = tempdir().unwrap();
2592 std::fs::write(
2593 project.path().join("mars.toml"),
2594 "[settings]\nmodels_cache_ttl_hours = 48\n",
2595 )
2596 .unwrap();
2597 let ctx = crate::types::MarsContext::for_test(
2598 project.path().to_path_buf(),
2599 project.path().join(".agents"),
2600 );
2601 assert_eq!(load_models_cache_ttl(&ctx), 48);
2602 }
2603}