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