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 resolve_model_id_for_alias(alias: &ModelAlias, cache: &ModelsCache) -> Option<String> {
1327 resolve_model_and_provider(alias, cache).map(|(model_id, _provider)| model_id)
1328}
1329
1330pub fn resolve_provider_for_alias(alias: &ModelAlias, cache: &ModelsCache) -> Option<String> {
1334 let provider = resolve_model_and_provider(alias, cache)
1335 .map(|(_model_id, provider)| provider)
1336 .or_else(|| provider_from_alias_spec(alias));
1337
1338 provider.filter(|value| !value.eq_ignore_ascii_case("unknown"))
1339}
1340
1341pub fn filter_by_visibility(
1346 mut aliases: IndexMap<String, ResolvedAlias>,
1347 visibility: &crate::config::ModelVisibility,
1348) -> IndexMap<String, ResolvedAlias> {
1349 let include = visibility
1350 .include
1351 .as_ref()
1352 .filter(|patterns| !patterns.is_empty());
1353 let exclude = visibility
1354 .exclude
1355 .as_ref()
1356 .filter(|patterns| !patterns.is_empty());
1357
1358 if include.is_none() && exclude.is_none() {
1359 return aliases;
1360 }
1361
1362 if let Some(includes) = include {
1363 aliases.retain(|_, alias| {
1364 let paths = alias
1365 .availability
1366 .as_ref()
1367 .map(|availability| availability.runnable_paths.as_slice())
1368 .unwrap_or(&[]);
1369 includes.iter().any(|pattern| {
1370 matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1371 })
1372 });
1373 }
1374
1375 if let Some(excludes) = exclude {
1376 aliases.retain(|_, alias| {
1377 let paths = alias
1378 .availability
1379 .as_ref()
1380 .map(|availability| availability.runnable_paths.as_slice())
1381 .unwrap_or(&[]);
1382 !excludes.iter().any(|pattern| {
1383 matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1384 })
1385 });
1386 }
1387 aliases
1388}
1389
1390fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
1391 match &alias.spec {
1392 ModelSpec::Pinned {
1393 model, provider, ..
1394 } => {
1395 let p = provider
1396 .clone()
1397 .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1398 .unwrap_or_else(|| "unknown".to_string());
1399 Some((model.clone(), p))
1400 }
1401 ModelSpec::PinnedWithMatch {
1402 model, provider, ..
1403 } => {
1404 let p = provider
1405 .clone()
1406 .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1407 .unwrap_or_else(|| "unknown".to_string());
1408 Some((model.clone(), p))
1409 }
1410 ModelSpec::AutoResolve {
1411 provider,
1412 match_patterns,
1413 exclude_patterns,
1414 } => {
1415 let model_id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
1416 Some((model_id, provider.clone()))
1417 }
1418 }
1419}
1420
1421fn provider_from_alias_spec(alias: &ModelAlias) -> Option<String> {
1422 match &alias.spec {
1423 ModelSpec::Pinned { model, provider }
1424 | ModelSpec::PinnedWithMatch {
1425 model, provider, ..
1426 } => provider
1427 .clone()
1428 .or_else(|| infer_provider_from_model_id(model).map(str::to_string)),
1429 ModelSpec::AutoResolve { provider, .. } => Some(provider.clone()),
1430 }
1431}
1432
1433fn format_alias_resolution_for_diag(
1434 alias: &ModelAlias,
1435 source_name: &str,
1436 cache: &ModelsCache,
1437) -> (String, Option<String>) {
1438 match &alias.spec {
1439 ModelSpec::Pinned { model, .. } => (
1440 format!("{source_name} → {model} (pinned)"),
1441 Some(model.clone()),
1442 ),
1443 ModelSpec::PinnedWithMatch { model, .. } => (
1444 format!("{source_name} → {model} (pinned+match)"),
1445 Some(model.clone()),
1446 ),
1447 ModelSpec::AutoResolve {
1448 provider,
1449 match_patterns,
1450 exclude_patterns,
1451 } => {
1452 let resolved = auto_resolve(provider, match_patterns, exclude_patterns, cache);
1453 match resolved {
1454 Some(model_id) => (format!("{source_name} → {model_id}"), Some(model_id)),
1455 None => (format!("{source_name} → <unresolvable>"), None),
1456 }
1457 }
1458 }
1459}
1460
1461fn resolve_harness(
1462 alias: &ModelAlias,
1463 provider: &str,
1464 installed: &HashSet<String>,
1465) -> (Option<String>, HarnessSource) {
1466 if let Some(h) = &alias.harness {
1467 if installed.contains(h) {
1468 (Some(h.clone()), HarnessSource::Explicit)
1469 } else {
1470 (Some(h.clone()), HarnessSource::Unavailable)
1471 }
1472 } else {
1473 match harness::resolve_harness_for_provider(provider, installed) {
1474 Some(h) => (Some(h), HarnessSource::AutoDetected),
1475 None => (None, HarnessSource::Unavailable),
1476 }
1477 }
1478}
1479
1480pub fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
1483 let id = model_id.to_lowercase();
1484 if id.starts_with("claude-") {
1485 return Some("anthropic");
1486 }
1487 if id.starts_with("gpt-")
1488 || id.starts_with("o1")
1489 || id.starts_with("o3")
1490 || id.starts_with("o4")
1491 || id.starts_with("codex-")
1492 {
1493 return Some("openai");
1494 }
1495 if id.starts_with("gemini") {
1496 return Some("google");
1497 }
1498 if id.starts_with("llama") {
1499 return Some("meta");
1500 }
1501 if id.starts_with("mistral") || id.starts_with("codestral") {
1502 return Some("mistral");
1503 }
1504 if id.starts_with("deepseek") {
1505 return Some("deepseek");
1506 }
1507 if id.starts_with("command") {
1508 return Some("cohere");
1509 }
1510 None
1511}
1512
1513#[cfg(test)]
1518mod tests {
1519 use super::*;
1520 use httpmock::prelude::*;
1521 use std::collections::HashSet;
1522 use std::sync::atomic::{AtomicUsize, Ordering};
1523 use std::sync::{Arc, mpsc};
1524 use std::thread;
1525 use tempfile::tempdir;
1526
1527 use serial_test::serial;
1528
1529 #[test]
1530 fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
1531 let raw = serde_json::json!({
1532 "anthropic": {
1533 "models": {
1534 "claude-opus-4-6": {
1535 "id": "claude-opus-4-6",
1536 "name": "Claude Opus 4.6",
1537 "release_date": "2026-02-05",
1538 "limit": {
1539 "context": 1000000,
1540 "output": 128000
1541 },
1542 "cost": {
1543 "input": 5.0,
1544 "output": 25.0,
1545 "cache_read": 0.5,
1546 "cache_write": 6.25,
1547 "reasoning": 15.0
1548 }
1549 }
1550 }
1551 },
1552 "openai": {
1553 "models": {
1554 "gpt-5": {
1555 "id": "gpt-5",
1556 "name": "GPT-5"
1557 }
1558 }
1559 },
1560 "random-host": {
1561 "models": {
1562 "foo": {
1563 "id": "foo"
1564 }
1565 }
1566 }
1567 });
1568
1569 let models = parse_models_dev_catalog(&raw).unwrap();
1570 assert_eq!(models.len(), 2);
1571
1572 let opus = models
1573 .iter()
1574 .find(|m| m.id == "claude-opus-4-6")
1575 .expect("missing claude-opus-4-6");
1576 assert_eq!(opus.provider, "Anthropic");
1577 assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1578 assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1579 assert_eq!(opus.context_window, Some(1_000_000));
1580 assert_eq!(opus.max_output, Some(128_000));
1581 assert_eq!(opus.cost_input, Some(5.0));
1582 assert_eq!(opus.cost_output, Some(25.0));
1583 assert_eq!(opus.cost_cache_read, Some(0.5));
1584 assert_eq!(opus.cost_cache_write, Some(6.25));
1585 assert_eq!(opus.cost_reasoning, Some(15.0));
1586
1587 let gpt = models
1588 .iter()
1589 .find(|m| m.id == "gpt-5")
1590 .expect("missing gpt-5");
1591 assert_eq!(gpt.provider, "OpenAI");
1592 assert_eq!(gpt.release_date, None);
1593 assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1594 assert_eq!(gpt.context_window, None);
1595 assert_eq!(gpt.max_output, None);
1596 assert_eq!(gpt.cost_input, None);
1597 assert_eq!(gpt.cost_output, None);
1598 assert_eq!(gpt.cost_cache_read, None);
1599 assert_eq!(gpt.cost_cache_write, None);
1600 assert_eq!(gpt.cost_reasoning, None);
1601 }
1602
1603 #[test]
1604 fn parse_models_dev_catalog_requires_object_root() {
1605 let raw = serde_json::json!(["not", "an", "object"]);
1606 let err = parse_models_dev_catalog(&raw).unwrap_err();
1607 assert!(err.to_string().contains("keyed by provider"));
1608 }
1609
1610 #[test]
1613 fn glob_exact_match() {
1614 assert!(glob_match("claude-opus-4", "claude-opus-4"));
1615 assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1616 }
1617
1618 #[test]
1619 fn glob_star_suffix() {
1620 assert!(glob_match("claude-opus-*", "claude-opus-4"));
1621 assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1622 assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1623 }
1624
1625 #[test]
1626 fn glob_star_prefix() {
1627 assert!(glob_match("*-opus-4", "claude-opus-4"));
1628 assert!(!glob_match("*-opus-4", "claude-opus-5"));
1629 }
1630
1631 #[test]
1632 fn glob_star_middle() {
1633 assert!(glob_match("claude-*-4", "claude-opus-4"));
1634 assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1635 assert!(!glob_match("claude-*-4", "claude-opus-5"));
1636 }
1637
1638 #[test]
1639 fn glob_multiple_stars() {
1640 assert!(glob_match("*claude*opus*", "claude-opus-4"));
1641 assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1642 assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1643 }
1644
1645 #[test]
1646 fn glob_star_only() {
1647 assert!(glob_match("*", "anything"));
1648 assert!(glob_match("*", ""));
1649 }
1650
1651 #[test]
1652 fn glob_empty_pattern() {
1653 assert!(glob_match("", ""));
1654 assert!(!glob_match("", "something"));
1655 }
1656
1657 fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1660 ModelsCache {
1661 models: models
1662 .into_iter()
1663 .map(|(id, provider, date)| CachedModel {
1664 id: id.to_string(),
1665 provider: provider.to_string(),
1666 release_date: date.map(String::from),
1667 description: None,
1668 context_window: None,
1669 max_output: None,
1670 cost_input: None,
1671 cost_output: None,
1672 cost_cache_read: None,
1673 cost_cache_write: None,
1674 cost_reasoning: None,
1675 })
1676 .collect(),
1677 fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1678 }
1679 }
1680
1681 #[test]
1682 fn auto_resolve_basic() {
1683 let cache = make_cache(vec![
1684 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1685 ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1686 ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1687 ]);
1688
1689 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1690 assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
1692 }
1693
1694 #[test]
1695 fn auto_resolve_exclude() {
1696 let cache = make_cache(vec![
1697 ("gpt-5", "OpenAI", Some("2025-06-01")),
1698 ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
1699 ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
1700 ]);
1701
1702 let result = auto_resolve(
1703 "OpenAI",
1704 &["gpt-*".to_string()],
1705 &["gpt-3*".to_string(), "gpt-4o*".to_string()],
1706 &cache,
1707 );
1708 assert_eq!(result, Some("gpt-5".to_string()));
1709 }
1710
1711 #[test]
1712 fn auto_resolve_skip_latest() {
1713 let cache = make_cache(vec![
1714 ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1715 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1716 ]);
1717
1718 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1719 assert_eq!(result, Some("claude-opus-4".to_string()));
1721 }
1722
1723 #[test]
1724 fn auto_resolve_empty_cache() {
1725 let cache = ModelsCache {
1726 models: Vec::new(),
1727 fetched_at: None,
1728 };
1729
1730 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1731 assert_eq!(result, None);
1732 }
1733
1734 #[test]
1735 fn auto_resolve_no_match() {
1736 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1737
1738 let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
1739 assert_eq!(result, None);
1740 }
1741
1742 #[test]
1743 fn auto_resolve_provider_case_insensitive() {
1744 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1745
1746 let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
1747 assert_eq!(result, Some("claude-opus-4".to_string()));
1748 }
1749
1750 #[test]
1751 fn auto_resolve_shortest_id_tiebreaker() {
1752 let cache = make_cache(vec![
1753 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1754 ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
1755 ]);
1756
1757 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1758 assert_eq!(result, Some("claude-opus-4".to_string()));
1760 }
1761
1762 #[test]
1763 fn auto_resolve_lexical_id_tiebreaker_when_date_and_length_equal() {
1764 let cache = make_cache(vec![
1765 ("claude-opus-4-b", "Anthropic", Some("2025-03-01")),
1766 ("claude-opus-4-a", "Anthropic", Some("2025-03-01")),
1767 ]);
1768
1769 let result = auto_resolve("Anthropic", &["claude-opus-4-*".to_string()], &[], &cache);
1770 assert_eq!(result, Some("claude-opus-4-a".to_string()));
1772 }
1773
1774 #[test]
1775 fn auto_resolve_all_returns_all_candidates() {
1776 let cache = make_cache(vec![
1777 ("claude-opus-4-5", "Anthropic", Some("2025-12-01")),
1778 ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1779 ("claude-opus-4-6-long", "Anthropic", Some("2026-02-05")),
1780 ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1781 ("claude-opus-3", "Anthropic", Some("2024-02-05")),
1782 ]);
1783
1784 let result = auto_resolve_all(
1785 "Anthropic",
1786 &["claude-opus-*".to_string()],
1787 &["*opus-3".to_string()],
1788 &cache,
1789 );
1790 let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
1791 assert_eq!(
1792 ids,
1793 vec!["claude-opus-4-6", "claude-opus-4-6-long", "claude-opus-4-5"]
1794 );
1795 }
1796
1797 fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
1800 ModelAlias {
1801 harness: harness.map(|h| h.to_string()),
1802 description: None,
1803 default_effort: None,
1804 autocompact: None,
1805 autocompact_pct: None,
1806 spec: ModelSpec::Pinned {
1807 model: model.to_string(),
1808 provider: None,
1809 },
1810 }
1811 }
1812
1813 fn auto_alias(
1814 provider: &str,
1815 match_patterns: &[&str],
1816 exclude_patterns: &[&str],
1817 ) -> ModelAlias {
1818 ModelAlias {
1819 harness: None,
1820 description: None,
1821 default_effort: None,
1822 autocompact: None,
1823 autocompact_pct: None,
1824 spec: ModelSpec::AutoResolve {
1825 provider: provider.to_string(),
1826 match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1827 exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
1828 },
1829 }
1830 }
1831
1832 fn pinned_match_alias(
1833 model: &str,
1834 provider: &str,
1835 match_patterns: &[&str],
1836 exclude_patterns: &[&str],
1837 ) -> ModelAlias {
1838 ModelAlias {
1839 harness: None,
1840 description: None,
1841 default_effort: None,
1842 autocompact: None,
1843 autocompact_pct: None,
1844 spec: ModelSpec::PinnedWithMatch {
1845 model: model.to_string(),
1846 provider: Some(provider.to_string()),
1847 match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1848 exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
1849 },
1850 }
1851 }
1852
1853 #[test]
1854 fn resolve_with_alias_prefix_basic() {
1855 let aliases = builtin_aliases();
1856 let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
1857
1858 let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
1859 assert_eq!(resolved.name, "opus-4-6");
1860 assert_eq!(resolved.model_id, "claude-opus-4-6");
1861 assert_eq!(resolved.provider, "anthropic");
1862 assert_eq!(
1863 resolved.harness_candidates,
1864 vec!["claude", "opencode", "gemini"]
1865 );
1866
1867 let installed = harness::detect_installed_harnesses();
1868 let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1869 let expected_source = if expected_harness.is_some() {
1870 HarnessSource::AutoDetected
1871 } else {
1872 HarnessSource::Unavailable
1873 };
1874 assert_eq!(resolved.harness, expected_harness);
1875 assert_eq!(resolved.harness_source, expected_source);
1876 }
1877
1878 #[test]
1879 fn resolve_with_alias_prefix_no_candidates() {
1880 let aliases = builtin_aliases();
1881 let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
1882
1883 let resolved = resolve_with_alias_prefix("opus-9-9", &aliases, &cache);
1884 assert!(resolved.is_none());
1885 }
1886
1887 #[test]
1888 fn resolve_with_alias_prefix_picks_newest() {
1889 let aliases = builtin_aliases();
1890 let cache = make_cache(vec![
1891 ("claude-opus-4-6-20250101", "Anthropic", Some("2025-01-01")),
1892 ("claude-opus-4-6-20260101", "Anthropic", Some("2026-01-01")),
1893 ]);
1894
1895 let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
1896 assert_eq!(resolved.model_id, "claude-opus-4-6-20260101");
1897 }
1898
1899 #[test]
1900 fn resolve_with_alias_prefix_lexical_id_tiebreaker_when_date_and_length_equal() {
1901 let aliases = builtin_aliases();
1902 let cache = make_cache(vec![
1903 ("claude-opus-4-b", "Anthropic", Some("2026-02-05")),
1904 ("claude-opus-4-a", "Anthropic", Some("2026-02-05")),
1905 ]);
1906
1907 let resolved = resolve_with_alias_prefix("opus-4-", &aliases, &cache).unwrap();
1908 assert_eq!(resolved.model_id, "claude-opus-4-a");
1909 }
1910
1911 #[test]
1912 fn resolve_with_alias_prefix_pinned_base_inherits_defaults() {
1913 let mut aliases = IndexMap::new();
1914 let mut alias = pinned_alias(Some("claude"), "claude-opus-4-6");
1915 alias.default_effort = Some("high".to_string());
1916 alias.autocompact = Some(42);
1917 aliases.insert("opus".to_string(), alias);
1918 let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
1919
1920 let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
1921 assert_eq!(resolved.model_id, "claude-opus-4-7");
1922 assert_eq!(resolved.default_effort.as_deref(), Some("high"));
1923 assert_eq!(resolved.autocompact, Some(42));
1924 }
1925
1926 #[test]
1927 fn resolve_with_alias_prefix_auto_base_does_not_inherit_defaults() {
1928 let mut aliases = IndexMap::new();
1929 let mut alias = auto_alias("anthropic", &["claude-opus-*"], &[]);
1930 alias.default_effort = Some("high".to_string());
1931 alias.autocompact = Some(42);
1932 aliases.insert("opus".to_string(), alias);
1933 let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
1934
1935 let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
1936 assert_eq!(resolved.model_id, "claude-opus-4-7");
1937 assert_eq!(resolved.default_effort, None);
1938 assert_eq!(resolved.autocompact, None);
1939 }
1940
1941 #[test]
1942 fn resolve_with_alias_prefix_exact_name_matches() {
1943 let aliases = builtin_aliases();
1948 let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
1949
1950 let resolved = resolve_with_alias_prefix("opus", &aliases, &cache);
1951 assert!(resolved.is_some());
1952 assert_eq!(resolved.unwrap().model_id, "claude-opus-4-6");
1953 }
1954
1955 #[test]
1956 fn resolve_with_alias_prefix_multiple_aliases_union() {
1957 let mut aliases = IndexMap::new();
1958 aliases.insert(
1959 "g".to_string(),
1960 auto_alias("openai", &["gpt-2026-08*"], &[]),
1961 );
1962 aliases.insert(
1963 "gpt".to_string(),
1964 auto_alias("openai", &["gpt-2026-03*"], &[]),
1965 );
1966 let cache = make_cache(vec![
1967 ("gpt-2026-03-01", "OpenAI", Some("2026-03-01")),
1968 ("gpt-2026-08-07", "OpenAI", Some("2026-08-07")),
1969 ]);
1970
1971 let resolved = resolve_with_alias_prefix("gpt-2026", &aliases, &cache).unwrap();
1972 assert_eq!(resolved.model_id, "gpt-2026-08-07");
1973 }
1974
1975 #[test]
1976 fn merge_empty_returns_builtins() {
1977 let mut diag = DiagnosticCollector::new();
1978 let merged = merge_model_config(&IndexMap::new(), &[], &mut diag, None);
1979 assert!(merged.contains_key("opus"));
1981 assert!(merged.contains_key("sonnet"));
1982 assert!(merged.contains_key("codex"));
1983 }
1984
1985 #[test]
1986 fn merge_consumer_overrides_dependency_alias() {
1987 let mut consumer = IndexMap::new();
1988 consumer.insert(
1989 "opus".to_string(),
1990 pinned_alias(Some("custom"), "my-opus-model"),
1991 );
1992
1993 let mut diag = DiagnosticCollector::new();
1994 let merged = merge_model_config(&consumer, &[], &mut diag, None);
1995 assert_eq!(
1996 merged.get("opus").unwrap().spec,
1997 ModelSpec::Pinned {
1998 model: "my-opus-model".to_string(),
1999 provider: None
2000 }
2001 );
2002 }
2003
2004 #[test]
2005 fn merge_dep_overrides_builtin() {
2006 let dep = ResolvedDepModels {
2007 source_name: "my-pkg".to_string(),
2008 models: {
2009 let mut m = IndexMap::new();
2010 m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
2011 m
2012 },
2013 };
2014
2015 let mut diag = DiagnosticCollector::new();
2016 let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag, None);
2017 assert_eq!(
2019 merged.get("opus").unwrap().spec,
2020 ModelSpec::Pinned {
2021 model: "pkg-opus".to_string(),
2022 provider: None
2023 }
2024 );
2025 }
2026
2027 #[test]
2028 fn merge_consumer_beats_dep() {
2029 let mut consumer = IndexMap::new();
2030 consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
2031
2032 let dep = ResolvedDepModels {
2033 source_name: "pkg".to_string(),
2034 models: {
2035 let mut m = IndexMap::new();
2036 m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
2037 m
2038 },
2039 };
2040
2041 let mut diag = DiagnosticCollector::new();
2042 let merged = merge_model_config(&consumer, &[dep], &mut diag, None);
2043 assert_eq!(
2044 merged.get("opus").unwrap().spec,
2045 ModelSpec::Pinned {
2046 model: "consumer-opus".to_string(),
2047 provider: None
2048 }
2049 );
2050 }
2051
2052 #[test]
2053 fn merge_dep_conflict_warns_with_winner_and_resolution_hint() {
2054 let dep1 = ResolvedDepModels {
2055 source_name: "pkg-a".to_string(),
2056 models: {
2057 let mut m = IndexMap::new();
2058 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2059 m
2060 },
2061 };
2062 let dep2 = ResolvedDepModels {
2063 source_name: "pkg-b".to_string(),
2064 models: {
2065 let mut m = IndexMap::new();
2066 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2067 m
2068 },
2069 };
2070
2071 let mut diag = DiagnosticCollector::new();
2072 let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2073 assert_eq!(
2075 merged.get("custom").unwrap().spec,
2076 ModelSpec::Pinned {
2077 model: "model-a".to_string(),
2078 provider: None
2079 }
2080 );
2081 let warnings = diag.drain();
2083 assert_eq!(warnings.len(), 1);
2084 assert_eq!(warnings[0].code, "model-alias-conflict");
2085 assert_eq!(
2086 warnings[0].message,
2087 "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"
2088 );
2089 }
2090
2091 #[test]
2092 fn merge_dep_conflict_with_cache_shows_resolution_diff() {
2093 let cache = make_cache(vec![
2094 ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2095 ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2096 ]);
2097 let dep1 = ResolvedDepModels {
2098 source_name: "dep-a".to_string(),
2099 models: {
2100 let mut m = IndexMap::new();
2101 m.insert(
2102 "opus".to_string(),
2103 pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2104 );
2105 m
2106 },
2107 };
2108 let dep2 = ResolvedDepModels {
2109 source_name: "dep-b".to_string(),
2110 models: {
2111 let mut m = IndexMap::new();
2112 m.insert(
2113 "opus".to_string(),
2114 pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2115 );
2116 m
2117 },
2118 };
2119
2120 let mut diag = DiagnosticCollector::new();
2121 let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2122 let warnings = diag.drain();
2123 assert_eq!(warnings.len(), 1);
2124 let message = &warnings[0].message;
2125 assert!(message.contains("dep-a → claude-opus-4-6 (pinned+match)"));
2126 assert!(message.contains("dep-b → claude-opus-4-7 (pinned+match)"));
2127 }
2128
2129 #[test]
2130 fn merge_dep_conflict_with_cache_same_resolution() {
2131 let cache = make_cache(vec![
2132 ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2133 ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2134 ]);
2135 let dep1 = ResolvedDepModels {
2136 source_name: "dep-a".to_string(),
2137 models: {
2138 let mut m = IndexMap::new();
2139 m.insert(
2140 "opus".to_string(),
2141 pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2142 );
2143 m
2144 },
2145 };
2146 let dep2 = ResolvedDepModels {
2147 source_name: "dep-b".to_string(),
2148 models: {
2149 let mut m = IndexMap::new();
2150 m.insert(
2151 "opus".to_string(),
2152 auto_alias("Anthropic", &["claude-opus-*"], &[]),
2153 );
2154 m
2155 },
2156 };
2157
2158 let mut diag = DiagnosticCollector::new();
2159 let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2160 let warnings = diag.drain();
2161 assert_eq!(warnings.len(), 1);
2162 assert!(
2163 warnings[0]
2164 .message
2165 .contains("both resolve to claude-opus-4-7")
2166 );
2167 }
2168
2169 #[test]
2170 fn merge_dep_conflict_without_cache_uses_old_format() {
2171 let dep1 = ResolvedDepModels {
2172 source_name: "dep-a".to_string(),
2173 models: {
2174 let mut m = IndexMap::new();
2175 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2176 m
2177 },
2178 };
2179 let dep2 = ResolvedDepModels {
2180 source_name: "dep-b".to_string(),
2181 models: {
2182 let mut m = IndexMap::new();
2183 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2184 m
2185 },
2186 };
2187
2188 let mut diag = DiagnosticCollector::new();
2189 let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2190 let warnings = diag.drain();
2191 assert_eq!(warnings.len(), 1);
2192 assert_eq!(
2193 warnings[0].message,
2194 "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"
2195 );
2196 }
2197
2198 #[test]
2199 fn merge_dep_three_way_conflict_warns_each_loser_against_first_winner() {
2200 let dep1 = ResolvedDepModels {
2201 source_name: "pkg-a".to_string(),
2202 models: {
2203 let mut m = IndexMap::new();
2204 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2205 m
2206 },
2207 };
2208 let dep2 = ResolvedDepModels {
2209 source_name: "pkg-b".to_string(),
2210 models: {
2211 let mut m = IndexMap::new();
2212 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2213 m
2214 },
2215 };
2216 let dep3 = ResolvedDepModels {
2217 source_name: "pkg-c".to_string(),
2218 models: {
2219 let mut m = IndexMap::new();
2220 m.insert("custom".to_string(), pinned_alias(Some("c"), "model-c"));
2221 m
2222 },
2223 };
2224
2225 let mut diag = DiagnosticCollector::new();
2226 let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2, dep3], &mut diag, None);
2227
2228 assert_eq!(
2229 merged.get("custom").unwrap().spec,
2230 ModelSpec::Pinned {
2231 model: "model-a".to_string(),
2232 provider: None
2233 }
2234 );
2235
2236 let warnings = diag.drain();
2237 assert_eq!(warnings.len(), 2);
2238 assert_eq!(
2239 warnings[0].message,
2240 "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"
2241 );
2242 assert_eq!(
2243 warnings[1].message,
2244 "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"
2245 );
2246 }
2247
2248 #[test]
2249 fn merge_consumer_override_suppresses_dep_conflict_warning() {
2250 let mut consumer = IndexMap::new();
2251 consumer.insert(
2252 "custom".to_string(),
2253 pinned_alias(Some("consumer"), "consumer-model"),
2254 );
2255
2256 let dep1 = ResolvedDepModels {
2257 source_name: "pkg-a".to_string(),
2258 models: {
2259 let mut m = IndexMap::new();
2260 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2261 m
2262 },
2263 };
2264 let dep2 = ResolvedDepModels {
2265 source_name: "pkg-b".to_string(),
2266 models: {
2267 let mut m = IndexMap::new();
2268 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2269 m
2270 },
2271 };
2272
2273 let mut diag = DiagnosticCollector::new();
2274 let merged = merge_model_config(&consumer, &[dep1, dep2], &mut diag, None);
2275
2276 assert_eq!(
2277 merged.get("custom").unwrap().spec,
2278 ModelSpec::Pinned {
2279 model: "consumer-model".to_string(),
2280 provider: None
2281 }
2282 );
2283 assert!(diag.drain().is_empty());
2284 }
2285
2286 #[test]
2287 fn merge_dep_conflicts_are_non_blocking() {
2288 let dep1 = ResolvedDepModels {
2289 source_name: "pkg-a".to_string(),
2290 models: {
2291 let mut m = IndexMap::new();
2292 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2293 m
2294 },
2295 };
2296 let dep2 = ResolvedDepModels {
2297 source_name: "pkg-b".to_string(),
2298 models: {
2299 let mut m = IndexMap::new();
2300 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2301 m.insert("extra".to_string(), pinned_alias(Some("b"), "model-extra"));
2302 m
2303 },
2304 };
2305
2306 let mut diag = DiagnosticCollector::new();
2307 let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2308
2309 assert!(merged.contains_key("opus"));
2310 assert_eq!(
2311 merged.get("custom").unwrap().spec,
2312 ModelSpec::Pinned {
2313 model: "model-a".to_string(),
2314 provider: None
2315 }
2316 );
2317 assert_eq!(
2318 merged.get("extra").unwrap().spec,
2319 ModelSpec::Pinned {
2320 model: "model-extra".to_string(),
2321 provider: None
2322 }
2323 );
2324 assert_eq!(diag.drain().len(), 1);
2325 }
2326
2327 #[test]
2330 fn resolve_all_pinned() {
2331 let mut aliases = IndexMap::new();
2332 aliases.insert(
2333 "fast".to_string(),
2334 pinned_alias(Some("claude"), "claude-haiku-4-5"),
2335 );
2336
2337 let cache = ModelsCache {
2338 models: Vec::new(),
2339 fetched_at: None,
2340 };
2341
2342 let mut diag = DiagnosticCollector::new();
2343 let resolved = resolve_all(&aliases, &cache, &mut diag);
2344 let entry = resolved.get("fast").unwrap();
2345 assert_eq!(entry.model_id, "claude-haiku-4-5");
2346 assert_eq!(entry.provider, "anthropic");
2347 }
2348
2349 #[test]
2350 fn resolve_all_copies_alias_defaults() {
2351 let mut aliases = IndexMap::new();
2352 let mut alias = pinned_alias(Some("claude"), "claude-haiku-4-5");
2353 alias.default_effort = Some("medium".to_string());
2354 alias.autocompact = Some(30);
2355 aliases.insert("fast".to_string(), alias);
2356
2357 let cache = ModelsCache {
2358 models: Vec::new(),
2359 fetched_at: None,
2360 };
2361
2362 let mut diag = DiagnosticCollector::new();
2363 let resolved = resolve_all(&aliases, &cache, &mut diag);
2364 let entry = resolved.get("fast").unwrap();
2365 assert_eq!(entry.default_effort.as_deref(), Some("medium"));
2366 assert_eq!(entry.autocompact, Some(30));
2367 }
2368
2369 #[test]
2370 fn resolve_all_pinned_with_provider() {
2371 let mut aliases = IndexMap::new();
2372 aliases.insert(
2373 "fast".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: "gpt-5.3-codex".to_string(),
2382 provider: Some("openai".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("fast").unwrap();
2395 assert_eq!(entry.model_id, "gpt-5.3-codex");
2396 assert_eq!(entry.provider, "openai");
2397 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
2398 }
2399
2400 #[test]
2401 fn resolve_all_pinned_auto_detect_harness() {
2402 let mut aliases = IndexMap::new();
2403 aliases.insert(
2404 "opus".to_string(),
2405 ModelAlias {
2406 harness: None,
2407 description: None,
2408 default_effort: None,
2409 autocompact: None,
2410 autocompact_pct: None,
2411 spec: ModelSpec::Pinned {
2412 model: "claude-opus-4-6".to_string(),
2413 provider: Some("anthropic".to_string()),
2414 },
2415 },
2416 );
2417
2418 let cache = ModelsCache {
2419 models: Vec::new(),
2420 fetched_at: None,
2421 };
2422
2423 let mut diag = DiagnosticCollector::new();
2424 let resolved = resolve_all(&aliases, &cache, &mut diag);
2425 let entry = resolved.get("opus").unwrap();
2426 assert_eq!(entry.model_id, "claude-opus-4-6");
2427 assert_eq!(entry.provider, "anthropic");
2428
2429 let installed = harness::detect_installed_harnesses();
2430 let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
2431 let expected_source = if expected_harness.is_some() {
2432 HarnessSource::AutoDetected
2433 } else {
2434 HarnessSource::Unavailable
2435 };
2436
2437 assert_eq!(entry.harness, expected_harness);
2438 assert_eq!(entry.harness_source, expected_source);
2439 }
2440
2441 #[test]
2442 fn resolve_all_auto_detect_harness() {
2443 let mut aliases = IndexMap::new();
2444 aliases.insert(
2445 "gpt".to_string(),
2446 ModelAlias {
2447 harness: None,
2448 description: None,
2449 default_effort: None,
2450 autocompact: None,
2451 autocompact_pct: None,
2452 spec: ModelSpec::AutoResolve {
2453 provider: "openai".to_string(),
2454 match_patterns: vec!["gpt-5*".to_string()],
2455 exclude_patterns: vec![],
2456 },
2457 },
2458 );
2459 let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
2460
2461 let mut diag = DiagnosticCollector::new();
2462 let resolved = resolve_all(&aliases, &cache, &mut diag);
2463 let entry = resolved.get("gpt").unwrap();
2464 assert_eq!(entry.model_id, "gpt-5");
2465 assert_eq!(entry.provider, "openai");
2466 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
2467 match entry.harness_source {
2468 HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
2469 HarnessSource::Unavailable => assert!(entry.harness.is_none()),
2470 HarnessSource::Explicit => panic!("unexpected explicit harness source"),
2471 }
2472 }
2473
2474 #[test]
2475 fn resolve_all_unavailable_harness_still_included() {
2476 let mut aliases = IndexMap::new();
2477 aliases.insert(
2478 "opus".to_string(),
2479 ModelAlias {
2480 harness: Some("missing-harness-xyz".to_string()),
2481 description: None,
2482 default_effort: None,
2483 autocompact: None,
2484 autocompact_pct: None,
2485 spec: ModelSpec::Pinned {
2486 model: "claude-opus-4-6".to_string(),
2487 provider: None,
2488 },
2489 },
2490 );
2491
2492 let cache = ModelsCache {
2493 models: Vec::new(),
2494 fetched_at: None,
2495 };
2496
2497 let mut diag = DiagnosticCollector::new();
2498 let resolved = resolve_all(&aliases, &cache, &mut diag);
2499 let entry = resolved.get("opus").unwrap();
2500 assert_eq!(entry.model_id, "claude-opus-4-6");
2501 assert_eq!(entry.provider, "anthropic");
2502 assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
2503 assert_eq!(entry.harness_source, HarnessSource::Unavailable);
2504 }
2505
2506 #[test]
2507 fn resolve_all_empty_cache_omits_unresolvable() {
2508 let mut aliases = IndexMap::new();
2509 aliases.insert(
2510 "opus".to_string(),
2511 ModelAlias {
2512 harness: Some("claude".to_string()),
2513 description: None,
2514 default_effort: None,
2515 autocompact: None,
2516 autocompact_pct: None,
2517 spec: ModelSpec::AutoResolve {
2518 provider: "Anthropic".to_string(),
2519 match_patterns: vec!["claude-opus-*".to_string()],
2520 exclude_patterns: vec![],
2521 },
2522 },
2523 );
2524 let cache = ModelsCache {
2525 models: Vec::new(),
2526 fetched_at: None,
2527 };
2528
2529 let mut diag = DiagnosticCollector::new();
2530 let resolved = resolve_all(&aliases, &cache, &mut diag);
2531 assert!(!resolved.contains_key("opus"));
2533 }
2534
2535 #[test]
2536 fn resolve_all_pinned_with_match_uses_model_field() {
2537 let mut aliases = IndexMap::new();
2538 aliases.insert(
2539 "opus".to_string(),
2540 pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2541 );
2542 let cache = make_cache(vec![
2543 ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2544 ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2545 ]);
2546
2547 let mut diag = DiagnosticCollector::new();
2548 let resolved = resolve_all(&aliases, &cache, &mut diag);
2549 assert_eq!(resolved.get("opus").unwrap().model_id, "claude-opus-4-6");
2550 assert!(diag.drain().is_empty());
2551 }
2552
2553 #[test]
2554 fn resolve_one_scopes_diagnostics_to_requested_alias() {
2555 let mut aliases = IndexMap::new();
2556 aliases.insert(
2557 "opus".to_string(),
2558 pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2559 );
2560 aliases.insert(
2561 "sonnet".to_string(),
2562 pinned_match_alias("claude-sonnet-4-5", "Anthropic", &["claude-sonnet-*"], &[]),
2563 );
2564 let cache = make_cache(vec![
2565 ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2566 ("claude-sonnet-4-7", "Anthropic", Some("2026-04-16")),
2567 ]);
2568
2569 let mut diag = DiagnosticCollector::new();
2570 let resolved = resolve_one("opus", &aliases, &cache, &mut diag).unwrap();
2571 assert_eq!(resolved.name, "opus");
2572 assert!(diag.drain().is_empty());
2573 }
2574
2575 fn make_resolved_alias(name: &str) -> ResolvedAlias {
2576 ResolvedAlias {
2577 name: name.to_string(),
2578 model_id: format!("model-{name}"),
2579 provider: "openai".to_string(),
2580 harness: Some("codex".to_string()),
2581 harness_source: HarnessSource::Explicit,
2582 harness_candidates: vec!["codex".to_string()],
2583 description: None,
2584 default_effort: None,
2585 autocompact: None,
2586 autocompact_pct: None,
2587 availability: None,
2588 }
2589 }
2590
2591 #[test]
2592 fn filter_by_visibility_include_mode_keeps_matches_only() {
2593 let mut aliases = IndexMap::new();
2594 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2595 aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2596 aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2597
2598 let filtered = filter_by_visibility(
2599 aliases,
2600 &crate::config::ModelVisibility {
2601 include: Some(vec!["model-opus*".to_string(), "model-gpt-*".to_string()]),
2602 exclude: None,
2603 },
2604 );
2605
2606 assert_eq!(filtered.len(), 2);
2607 assert!(filtered.contains_key("opus"));
2608 assert!(filtered.contains_key("gpt-5"));
2609 assert!(!filtered.contains_key("sonnet"));
2610 }
2611
2612 #[test]
2613 fn filter_by_visibility_exclude_mode_removes_matches() {
2614 let mut aliases = IndexMap::new();
2615 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2616 aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
2617 aliases.insert(
2618 "deprecated-gpt".to_string(),
2619 make_resolved_alias("deprecated-gpt"),
2620 );
2621
2622 let filtered = filter_by_visibility(
2623 aliases,
2624 &crate::config::ModelVisibility {
2625 include: None,
2626 exclude: Some(vec![
2627 "model-test-*".to_string(),
2628 "model-deprecated-*".to_string(),
2629 ]),
2630 },
2631 );
2632
2633 assert_eq!(filtered.len(), 1);
2634 assert!(filtered.contains_key("opus"));
2635 assert!(!filtered.contains_key("test-opus"));
2636 assert!(!filtered.contains_key("deprecated-gpt"));
2637 }
2638
2639 #[test]
2640 fn filter_by_visibility_empty_config_returns_all() {
2641 let mut aliases = IndexMap::new();
2642 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2643 aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2644 let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
2645 assert_eq!(filtered.len(), 2);
2646 assert!(filtered.contains_key("opus"));
2647 assert!(filtered.contains_key("sonnet"));
2648 }
2649
2650 #[test]
2651 fn filter_by_visibility_empty_lists_return_all() {
2652 let mut aliases = IndexMap::new();
2653 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2654 aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2655 let filtered = filter_by_visibility(
2656 aliases,
2657 &crate::config::ModelVisibility {
2658 include: Some(Vec::new()),
2659 exclude: Some(Vec::new()),
2660 },
2661 );
2662 assert_eq!(filtered.len(), 2);
2663 assert!(filtered.contains_key("opus"));
2664 assert!(filtered.contains_key("sonnet"));
2665 }
2666
2667 #[test]
2668 fn visibility_pattern_matches_bare_provider_and_opencode_slug_forms() {
2669 let paths = vec![availability::RunnablePath {
2670 harness: "opencode".to_string(),
2671 mars_provider: "Anthropic".to_string(),
2672 harness_model_id: "openrouter/anthropic/claude-opus-4.7".to_string(),
2673 }];
2674
2675 assert!(matches_visibility_pattern(
2676 "claude-opus-*",
2677 "claude-opus-4-7",
2678 "Anthropic",
2679 &paths
2680 ));
2681 assert!(matches_visibility_pattern(
2682 "anthropic/claude-opus-*",
2683 "claude-opus-4-7",
2684 "Anthropic",
2685 &paths
2686 ));
2687 assert!(matches_visibility_pattern(
2688 "openrouter/anthropic/*",
2689 "claude-opus-4-7",
2690 "Anthropic",
2691 &paths
2692 ));
2693 assert!(!matches_visibility_pattern(
2694 "anthropic/*/opus",
2695 "claude-opus-4-7",
2696 "Anthropic",
2697 &paths
2698 ));
2699 }
2700
2701 #[test]
2702 fn filter_by_visibility_applies_include_then_exclude() {
2703 let mut aliases = IndexMap::new();
2704 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2705 aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2706 aliases.insert("gpt-4".to_string(), make_resolved_alias("gpt-4"));
2707
2708 let filtered = filter_by_visibility(
2709 aliases,
2710 &crate::config::ModelVisibility {
2711 include: Some(vec!["openai/model-*".to_string()]),
2712 exclude: Some(vec!["model-gpt-4".to_string()]),
2713 },
2714 );
2715
2716 assert_eq!(filtered.len(), 2);
2717 assert!(filtered.contains_key("opus"));
2718 assert!(filtered.contains_key("gpt-5"));
2719 assert!(!filtered.contains_key("gpt-4"));
2720 }
2721
2722 #[test]
2723 fn resolve_model_and_provider_pinned_explicit_provider() {
2724 let alias = ModelAlias {
2725 harness: None,
2726 description: None,
2727 default_effort: None,
2728 autocompact: None,
2729 autocompact_pct: None,
2730 spec: ModelSpec::Pinned {
2731 model: "claude-opus-4-6".to_string(),
2732 provider: Some("anthropic".to_string()),
2733 },
2734 };
2735 let cache = ModelsCache {
2736 models: Vec::new(),
2737 fetched_at: None,
2738 };
2739
2740 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2741 assert_eq!(
2742 resolved,
2743 ("claude-opus-4-6".to_string(), "anthropic".to_string())
2744 );
2745 }
2746
2747 #[test]
2748 fn resolve_model_and_provider_pinned_inferred() {
2749 let alias = ModelAlias {
2750 harness: None,
2751 description: None,
2752 default_effort: None,
2753 autocompact: None,
2754 autocompact_pct: None,
2755 spec: ModelSpec::Pinned {
2756 model: "claude-opus-4-6".to_string(),
2757 provider: None,
2758 },
2759 };
2760 let cache = ModelsCache {
2761 models: Vec::new(),
2762 fetched_at: None,
2763 };
2764
2765 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2766 assert_eq!(
2767 resolved,
2768 ("claude-opus-4-6".to_string(), "anthropic".to_string())
2769 );
2770 }
2771
2772 #[test]
2773 fn resolve_model_and_provider_pinned_unknown() {
2774 let alias = ModelAlias {
2775 harness: None,
2776 description: None,
2777 default_effort: None,
2778 autocompact: None,
2779 autocompact_pct: None,
2780 spec: ModelSpec::Pinned {
2781 model: "my-custom-model".to_string(),
2782 provider: None,
2783 },
2784 };
2785 let cache = ModelsCache {
2786 models: Vec::new(),
2787 fetched_at: None,
2788 };
2789
2790 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2791 assert_eq!(
2792 resolved,
2793 ("my-custom-model".to_string(), "unknown".to_string())
2794 );
2795 }
2796
2797 #[test]
2798 fn resolve_model_and_provider_auto_resolve() {
2799 let alias = ModelAlias {
2800 harness: None,
2801 description: None,
2802 default_effort: None,
2803 autocompact: None,
2804 autocompact_pct: None,
2805 spec: ModelSpec::AutoResolve {
2806 provider: "openai".to_string(),
2807 match_patterns: vec!["gpt-5*".to_string()],
2808 exclude_patterns: vec![],
2809 },
2810 };
2811 let cache = make_cache(vec![
2812 ("gpt-4o", "OpenAI", Some("2024-06-01")),
2813 ("gpt-5", "OpenAI", Some("2025-06-01")),
2814 ]);
2815
2816 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2817 assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
2818 }
2819
2820 #[test]
2821 fn resolve_harness_explicit_installed() {
2822 let alias = ModelAlias {
2823 harness: Some("claude".to_string()),
2824 description: None,
2825 default_effort: None,
2826 autocompact: None,
2827 autocompact_pct: None,
2828 spec: ModelSpec::Pinned {
2829 model: "claude-opus-4-6".to_string(),
2830 provider: None,
2831 },
2832 };
2833 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
2834
2835 let resolved = resolve_harness(&alias, "anthropic", &installed);
2836 assert_eq!(
2837 resolved,
2838 (Some("claude".to_string()), HarnessSource::Explicit)
2839 );
2840 }
2841
2842 #[test]
2843 fn resolve_harness_explicit_not_installed() {
2844 let alias = ModelAlias {
2845 harness: Some("claude".to_string()),
2846 description: None,
2847 default_effort: None,
2848 autocompact: None,
2849 autocompact_pct: None,
2850 spec: ModelSpec::Pinned {
2851 model: "claude-opus-4-6".to_string(),
2852 provider: None,
2853 },
2854 };
2855 let installed = HashSet::new();
2856
2857 let resolved = resolve_harness(&alias, "anthropic", &installed);
2858 assert_eq!(
2859 resolved,
2860 (Some("claude".to_string()), HarnessSource::Unavailable)
2861 );
2862 }
2863
2864 #[test]
2865 fn resolve_harness_auto_detected() {
2866 let alias = ModelAlias {
2867 harness: None,
2868 description: None,
2869 default_effort: None,
2870 autocompact: None,
2871 autocompact_pct: None,
2872 spec: ModelSpec::Pinned {
2873 model: "claude-opus-4-6".to_string(),
2874 provider: Some("anthropic".to_string()),
2875 },
2876 };
2877 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
2878
2879 let resolved = resolve_harness(&alias, "anthropic", &installed);
2880 assert_eq!(
2881 resolved,
2882 (Some("claude".to_string()), HarnessSource::AutoDetected)
2883 );
2884 }
2885
2886 #[test]
2887 fn resolve_harness_unavailable() {
2888 let alias = ModelAlias {
2889 harness: None,
2890 description: None,
2891 default_effort: None,
2892 autocompact: None,
2893 autocompact_pct: None,
2894 spec: ModelSpec::Pinned {
2895 model: "claude-opus-4-6".to_string(),
2896 provider: Some("anthropic".to_string()),
2897 },
2898 };
2899 let installed = HashSet::new();
2900
2901 let resolved = resolve_harness(&alias, "anthropic", &installed);
2902 assert_eq!(resolved, (None, HarnessSource::Unavailable));
2903 }
2904
2905 #[test]
2906 fn resolve_harness_unavailable_no_provider_match() {
2907 let alias = ModelAlias {
2908 harness: None,
2909 description: None,
2910 default_effort: None,
2911 autocompact: None,
2912 autocompact_pct: None,
2913 spec: ModelSpec::Pinned {
2914 model: "my-custom-model".to_string(),
2915 provider: Some("unknown".to_string()),
2916 },
2917 };
2918 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
2919
2920 let resolved = resolve_harness(&alias, "unknown", &installed);
2921 assert_eq!(resolved, (None, HarnessSource::Unavailable));
2922 }
2923
2924 #[test]
2927 fn harness_source_serializes_snake_case() {
2928 assert_eq!(
2929 serde_json::to_string(&HarnessSource::Explicit).unwrap(),
2930 "\"explicit\""
2931 );
2932 assert_eq!(
2933 serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
2934 "\"auto_detected\""
2935 );
2936 assert_eq!(
2937 serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
2938 "\"unavailable\""
2939 );
2940 }
2941
2942 #[test]
2943 fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
2944 let toml_str = r#"
2945[models.fast]
2946harness = "claude"
2947model = "claude-haiku-4-5"
2948description = "Fast and cheap"
2949"#;
2950
2951 #[derive(Debug, Deserialize)]
2952 struct Wrapper {
2953 #[allow(dead_code)]
2954 models: IndexMap<String, ModelAlias>,
2955 }
2956
2957 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
2958 let alias = parsed.models.get("fast").unwrap();
2959 assert_eq!(
2960 alias.spec,
2961 ModelSpec::Pinned {
2962 model: "claude-haiku-4-5".to_string(),
2963 provider: None
2964 }
2965 );
2966 assert_eq!(alias.harness.as_deref(), Some("claude"));
2967 assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
2968
2969 let json = serde_json::to_string(alias).unwrap();
2970 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
2971 assert_eq!(roundtripped, *alias);
2972 }
2973
2974 #[test]
2975 fn model_alias_pinned_toml_roundtrip_without_harness() {
2976 let toml_str = r#"
2977[models.fast]
2978model = "claude-haiku-4-5"
2979"#;
2980
2981 #[derive(Debug, Deserialize)]
2982 struct Wrapper {
2983 #[allow(dead_code)]
2984 models: IndexMap<String, ModelAlias>,
2985 }
2986
2987 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
2988 let alias = parsed.models.get("fast").unwrap();
2989 assert_eq!(alias.harness, None);
2990 assert_eq!(
2991 alias.spec,
2992 ModelSpec::Pinned {
2993 model: "claude-haiku-4-5".to_string(),
2994 provider: None
2995 }
2996 );
2997
2998 let json = serde_json::to_string(alias).unwrap();
2999 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3000 assert!(value.get("harness").is_none());
3001 assert!(value.get("provider").is_none());
3002 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3003 assert_eq!(roundtripped, *alias);
3004 }
3005
3006 #[test]
3007 fn model_alias_pinned_toml_roundtrip_with_provider() {
3008 let toml_str = r#"
3009[models.fast]
3010model = "claude-haiku-4-5"
3011provider = "anthropic"
3012"#;
3013
3014 #[derive(Debug, Deserialize)]
3015 struct Wrapper {
3016 #[allow(dead_code)]
3017 models: IndexMap<String, ModelAlias>,
3018 }
3019
3020 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3021 let alias = parsed.models.get("fast").unwrap();
3022 assert_eq!(alias.harness, None);
3023 assert_eq!(
3024 alias.spec,
3025 ModelSpec::Pinned {
3026 model: "claude-haiku-4-5".to_string(),
3027 provider: Some("anthropic".to_string())
3028 }
3029 );
3030
3031 let json = serde_json::to_string(alias).unwrap();
3032 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3033 assert_eq!(
3034 value.get("provider").and_then(serde_json::Value::as_str),
3035 Some("anthropic")
3036 );
3037 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3038 assert_eq!(roundtripped, *alias);
3039 }
3040
3041 #[test]
3042 fn model_alias_pinned_json_roundtrip_with_provider() {
3043 let json = r#"{
3044 "model": "gpt-5.3-codex",
3045 "provider": "openai"
3046 }"#;
3047
3048 let alias: ModelAlias = serde_json::from_str(json).unwrap();
3049 assert_eq!(alias.harness, None);
3050 assert_eq!(alias.description, None);
3051 assert_eq!(
3052 alias.spec,
3053 ModelSpec::Pinned {
3054 model: "gpt-5.3-codex".to_string(),
3055 provider: Some("openai".to_string())
3056 }
3057 );
3058
3059 let encoded = serde_json::to_string(&alias).unwrap();
3060 let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
3061 assert_eq!(roundtripped, alias);
3062 }
3063
3064 #[test]
3065 fn model_alias_auto_resolve_toml_roundtrip() {
3066 let toml_str = r#"
3067[models.opus]
3068harness = "claude"
3069provider = "Anthropic"
3070match = ["claude-opus-*"]
3071exclude = ["claude-opus-3*"]
3072description = "Best reasoning"
3073"#;
3074
3075 #[derive(Debug, Deserialize)]
3076 struct Wrapper {
3077 #[allow(dead_code)]
3078 models: IndexMap<String, ModelAlias>,
3079 }
3080
3081 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3082 let alias = parsed.models.get("opus").unwrap();
3083 assert_eq!(alias.harness.as_deref(), Some("claude"));
3084 match &alias.spec {
3085 ModelSpec::AutoResolve {
3086 provider,
3087 match_patterns,
3088 exclude_patterns,
3089 } => {
3090 assert_eq!(provider, "Anthropic");
3091 assert_eq!(match_patterns, &["claude-opus-*"]);
3092 assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3093 }
3094 _ => panic!("expected AutoResolve"),
3095 }
3096 }
3097
3098 #[test]
3099 fn model_alias_model_and_match_toml_roundtrip() {
3100 let toml_str = r#"
3101[models.opus]
3102model = "claude-opus-4-6"
3103provider = "anthropic"
3104match = ["claude-opus-*"]
3105exclude = ["claude-opus-3*"]
3106"#;
3107
3108 #[derive(Debug, Deserialize)]
3109 struct Wrapper {
3110 #[allow(dead_code)]
3111 models: IndexMap<String, ModelAlias>,
3112 }
3113
3114 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3115 let alias = parsed.models.get("opus").unwrap();
3116 match &alias.spec {
3117 ModelSpec::PinnedWithMatch {
3118 model,
3119 provider,
3120 match_patterns,
3121 exclude_patterns,
3122 } => {
3123 assert_eq!(model, "claude-opus-4-6");
3124 assert_eq!(provider.as_deref(), Some("anthropic"));
3125 assert_eq!(match_patterns, &["claude-opus-*"]);
3126 assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3127 }
3128 _ => panic!("expected PinnedWithMatch"),
3129 }
3130
3131 let json = serde_json::to_string(alias).unwrap();
3132 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3133 assert_eq!(roundtripped, *alias);
3134 }
3135
3136 #[test]
3137 fn model_alias_model_with_exclude_without_match_errors() {
3138 let toml_str = r#"
3139[models.opus]
3140model = "claude-opus-4-7"
3141exclude = ["claude-opus-3*"]
3142"#;
3143
3144 #[derive(Debug, Deserialize)]
3145 struct Wrapper {
3146 #[allow(dead_code)]
3147 models: IndexMap<String, ModelAlias>,
3148 }
3149
3150 let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3151 assert!(err.contains("must also include 'match'"));
3152 }
3153
3154 #[test]
3155 fn model_alias_defaults_toml_roundtrip() {
3156 let toml_str = r#"
3157[models.opus]
3158provider = "Anthropic"
3159match = ["claude-opus-*"]
3160default_effort = "high"
3161autocompact = 25
3162"#;
3163
3164 #[derive(Debug, Deserialize)]
3165 struct Wrapper {
3166 models: IndexMap<String, ModelAlias>,
3167 }
3168
3169 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3170 let alias = parsed.models.get("opus").unwrap();
3171 assert_eq!(alias.default_effort.as_deref(), Some("high"));
3172 assert_eq!(alias.autocompact, Some(25));
3173
3174 let json = serde_json::to_string(alias).unwrap();
3175 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3176 assert_eq!(roundtripped, *alias);
3177 }
3178
3179 #[test]
3180 fn model_alias_empty_default_effort_treated_as_none() {
3181 let toml_str = r#"
3182[models.opus]
3183provider = "Anthropic"
3184match = ["claude-opus-*"]
3185default_effort = ""
3186"#;
3187
3188 #[derive(Debug, Deserialize)]
3189 struct Wrapper {
3190 models: IndexMap<String, ModelAlias>,
3191 }
3192
3193 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3194 let alias = parsed.models.get("opus").unwrap();
3195 assert_eq!(alias.default_effort, None);
3196 }
3197
3198 #[test]
3199 fn model_alias_invalid_default_effort_errors() {
3200 let toml_str = r#"
3201[models.opus]
3202provider = "Anthropic"
3203match = ["claude-opus-*"]
3204default_effort = "maximum"
3205"#;
3206
3207 #[derive(Debug, Deserialize)]
3208 struct Wrapper {
3209 #[allow(dead_code)]
3210 models: IndexMap<String, ModelAlias>,
3211 }
3212
3213 let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3214 assert!(err.contains("invalid default_effort"));
3215 assert!(err.contains("accepted values"));
3216 }
3217
3218 #[test]
3219 fn model_alias_autocompact_out_of_range_errors() {
3220 let toml_str = r#"
3222[models.opus]
3223provider = "Anthropic"
3224match = ["claude-opus-*"]
3225autocompact_pct = 101
3226"#;
3227
3228 #[derive(Debug, Deserialize)]
3229 struct Wrapper {
3230 #[allow(dead_code)]
3231 models: IndexMap<String, ModelAlias>,
3232 }
3233
3234 let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3235 assert!(err.contains("out of range 1-100"));
3236 }
3237
3238 #[test]
3239 fn model_alias_autocompact_boolean_errors() {
3240 let toml_str = r#"
3241[models.opus]
3242provider = "Anthropic"
3243match = ["claude-opus-*"]
3244autocompact = true
3245"#;
3246
3247 #[derive(Debug, Deserialize)]
3248 struct Wrapper {
3249 #[allow(dead_code)]
3250 models: IndexMap<String, ModelAlias>,
3251 }
3252
3253 let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3254 assert!(err.contains("autocompact must be an integer (token count)"));
3255 }
3256
3257 #[test]
3258 fn parses_autocompact_pct() {
3259 let toml_str = r#"
3260[models.opus]
3261provider = "Anthropic"
3262match = ["claude-opus-*"]
3263autocompact_pct = 75
3264"#;
3265
3266 #[derive(Debug, Deserialize)]
3267 struct Wrapper {
3268 models: IndexMap<String, ModelAlias>,
3269 }
3270
3271 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3272 let alias = parsed.models.get("opus").unwrap();
3273 assert_eq!(alias.autocompact_pct, Some(75));
3274 assert_eq!(alias.autocompact, None);
3275 }
3276
3277 #[test]
3278 fn autocompact_pct_out_of_range_errors() {
3279 let toml_str = r#"
3280[models.opus]
3281provider = "Anthropic"
3282match = ["claude-opus-*"]
3283autocompact_pct = 150
3284"#;
3285
3286 #[derive(Debug, Deserialize)]
3287 struct Wrapper {
3288 #[allow(dead_code)]
3289 models: IndexMap<String, ModelAlias>,
3290 }
3291
3292 let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3293 assert!(err.contains("autocompact_pct"));
3294 assert!(err.contains("out of range 1-100"));
3295 }
3296
3297 #[test]
3298 fn autocompact_pct_zero_errors() {
3299 let toml_str = r#"
3300[models.opus]
3301provider = "Anthropic"
3302match = ["claude-opus-*"]
3303autocompact_pct = 0
3304"#;
3305
3306 #[derive(Debug, Deserialize)]
3307 struct Wrapper {
3308 #[allow(dead_code)]
3309 models: IndexMap<String, ModelAlias>,
3310 }
3311
3312 let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3313 assert!(err.contains("autocompact_pct"));
3314 assert!(err.contains("out of range 1-100"));
3315 }
3316
3317 #[test]
3318 fn model_alias_autocompact_zero_accepted() {
3319 let toml_str = r#"
3320[models.opus]
3321model = "claude-opus-4-6"
3322autocompact = 0
3323"#;
3324
3325 #[derive(Debug, Deserialize)]
3326 struct Wrapper {
3327 models: IndexMap<String, ModelAlias>,
3328 }
3329
3330 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3331 let alias = parsed.models.get("opus").unwrap();
3332 assert_eq!(alias.autocompact, Some(0u32));
3333 }
3334
3335 #[test]
3336 fn model_alias_autocompact_max_u32_accepted() {
3337 let toml_str = r#"
3338[models.opus]
3339model = "claude-opus-4-6"
3340autocompact = 4294967295
3341"#;
3342
3343 #[derive(Debug, Deserialize)]
3344 struct Wrapper {
3345 models: IndexMap<String, ModelAlias>,
3346 }
3347
3348 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3349 let alias = parsed.models.get("opus").unwrap();
3350 assert_eq!(alias.autocompact, Some(4294967295u32));
3351 }
3352
3353 #[test]
3354 fn model_alias_autocompact_overflow_errors() {
3355 let toml_str = r#"
3357[models.opus]
3358model = "claude-opus-4-6"
3359autocompact = 4294967296
3360"#;
3361
3362 #[derive(Debug, Deserialize)]
3363 struct Wrapper {
3364 #[allow(dead_code)]
3365 models: IndexMap<String, ModelAlias>,
3366 }
3367
3368 let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3369 assert!(err.contains("out of u32 range"));
3370 }
3371
3372 #[test]
3373 fn both_autocompact_fields_round_trip() {
3374 let toml_str = r#"
3375[models.opus]
3376model = "claude-opus-4-6"
3377autocompact = 50000
3378autocompact_pct = 80
3379"#;
3380
3381 #[derive(Debug, Deserialize)]
3382 struct Wrapper {
3383 models: IndexMap<String, ModelAlias>,
3384 }
3385
3386 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3387 let alias = parsed.models.get("opus").unwrap();
3388 assert_eq!(alias.autocompact, Some(50000u32));
3389 assert_eq!(alias.autocompact_pct, Some(80u8));
3390
3391 let mut aliases = IndexMap::new();
3393 aliases.insert("opus".to_string(), alias.clone());
3394 let cache = ModelsCache {
3395 models: Vec::new(),
3396 fetched_at: None,
3397 };
3398 let mut diag = DiagnosticCollector::new();
3399 let resolved = resolve_all(&aliases, &cache, &mut diag);
3400 let entry = resolved.get("opus").unwrap();
3401 assert_eq!(entry.autocompact, Some(50000u32));
3402 assert_eq!(entry.autocompact_pct, Some(80u8));
3403 }
3404
3405 #[test]
3406 fn model_alias_both_model_and_match_is_hybrid_pinned() {
3407 let toml_str = r#"
3408[models.bad]
3409harness = "claude"
3410model = "some-model"
3411match = ["pattern-*"]
3412"#;
3413
3414 #[derive(Debug, Deserialize)]
3415 struct Wrapper {
3416 #[allow(dead_code)]
3417 models: IndexMap<String, ModelAlias>,
3418 }
3419
3420 let result = toml::from_str::<Wrapper>(toml_str).unwrap();
3421 let alias = result.models.get("bad").unwrap();
3422 match &alias.spec {
3423 ModelSpec::PinnedWithMatch {
3424 model,
3425 match_patterns,
3426 ..
3427 } => {
3428 assert_eq!(model, "some-model");
3429 assert_eq!(match_patterns, &["pattern-*"]);
3430 }
3431 _ => panic!("expected pinned-with-match alias"),
3432 }
3433 }
3434
3435 #[test]
3436 fn model_alias_neither_model_nor_match_errors() {
3437 let toml_str = r#"
3438[models.bad]
3439harness = "claude"
3440"#;
3441
3442 #[derive(Debug, Deserialize)]
3443 struct Wrapper {
3444 #[allow(dead_code)]
3445 models: IndexMap<String, ModelAlias>,
3446 }
3447
3448 let result = toml::from_str::<Wrapper>(toml_str);
3449 assert!(result.is_err());
3450 }
3451
3452 #[test]
3453 fn infer_provider_from_model_id_detects_known_prefixes() {
3454 assert_eq!(
3455 infer_provider_from_model_id("claude-opus-4-6"),
3456 Some("anthropic")
3457 );
3458 assert_eq!(
3459 infer_provider_from_model_id("gpt-5.3-codex"),
3460 Some("openai")
3461 );
3462 assert_eq!(
3463 infer_provider_from_model_id("gemini-2.5-pro"),
3464 Some("google")
3465 );
3466 assert_eq!(
3467 infer_provider_from_model_id("llama-4-maverick"),
3468 Some("meta")
3469 );
3470 assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
3471 assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
3472 assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
3473 assert_eq!(
3474 infer_provider_from_model_id("codex-mini-latest"),
3475 Some("openai")
3476 );
3477 assert_eq!(
3478 infer_provider_from_model_id("mistral-large"),
3479 Some("mistral")
3480 );
3481 assert_eq!(
3482 infer_provider_from_model_id("codestral-latest"),
3483 Some("mistral")
3484 );
3485 assert_eq!(
3486 infer_provider_from_model_id("deepseek-chat"),
3487 Some("deepseek")
3488 );
3489 assert_eq!(
3490 infer_provider_from_model_id("command-r-plus"),
3491 Some("cohere")
3492 );
3493 }
3494
3495 #[test]
3496 fn infer_provider_from_model_id_returns_none_for_unknown_model() {
3497 assert_eq!(infer_provider_from_model_id("unknown-model"), None);
3498 }
3499
3500 #[test]
3501 fn infer_provider_from_model_id_returns_none_for_empty_string() {
3502 assert_eq!(infer_provider_from_model_id(""), None);
3503 }
3504
3505 #[test]
3506 fn infer_provider_from_model_id_is_case_insensitive() {
3507 assert_eq!(
3508 infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
3509 Some("anthropic")
3510 );
3511 assert_eq!(
3512 infer_provider_from_model_id("GPT-5.3-codex"),
3513 Some("openai")
3514 );
3515 assert_eq!(
3516 infer_provider_from_model_id("CoDeStRaL-latest"),
3517 Some("mistral")
3518 );
3519 }
3520
3521 #[allow(unused_unsafe)]
3522 fn env_set(key: &str, value: &str) {
3523 unsafe {
3524 std::env::set_var(key, value);
3525 }
3526 }
3527
3528 #[allow(unused_unsafe)]
3529 fn env_remove(key: &str) {
3530 unsafe {
3531 std::env::remove_var(key);
3532 }
3533 }
3534
3535 struct EnvVarGuard {
3536 key: String,
3537 prev: Option<String>,
3538 }
3539
3540 impl EnvVarGuard {
3541 fn set(key: &str, value: &str) -> Self {
3542 let prev = std::env::var(key).ok();
3543 env_set(key, value);
3544 Self {
3545 key: key.to_string(),
3546 prev,
3547 }
3548 }
3549 }
3550
3551 impl Drop for EnvVarGuard {
3552 fn drop(&mut self) {
3553 if let Some(prev) = &self.prev {
3554 env_set(&self.key, prev);
3555 } else {
3556 env_remove(&self.key);
3557 }
3558 }
3559 }
3560
3561 fn sample_catalog_json() -> serde_json::Value {
3562 serde_json::json!({
3563 "openai": {
3564 "models": {
3565 "gpt-5": {
3566 "id": "gpt-5",
3567 "name": "GPT-5",
3568 "release_date": "2025-06-01",
3569 "limit": {
3570 "context": 400000,
3571 "output": 128000
3572 }
3573 }
3574 }
3575 },
3576 "anthropic": {
3577 "models": {
3578 "claude-sonnet-4-5": {
3579 "id": "claude-sonnet-4-5",
3580 "name": "Claude Sonnet 4.5",
3581 "release_date": "2025-03-01"
3582 }
3583 }
3584 }
3585 })
3586 }
3587
3588 fn sample_cached_model(id: &str) -> CachedModel {
3589 CachedModel {
3590 id: id.to_string(),
3591 provider: "OpenAI".to_string(),
3592 release_date: None,
3593 description: None,
3594 context_window: None,
3595 max_output: None,
3596 cost_input: None,
3597 cost_output: None,
3598 cost_cache_read: None,
3599 cost_cache_write: None,
3600 cost_reasoning: None,
3601 }
3602 }
3603
3604 fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
3605 write_cache(
3606 mars_dir,
3607 &ModelsCache {
3608 models,
3609 fetched_at: Some(fetched_at.to_string()),
3610 },
3611 )
3612 .expect("failed to write cache fixture");
3613 }
3614
3615 fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
3616 std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
3617 std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
3618 }
3619
3620 fn stale_timestamp() -> String {
3621 now_unix_secs_value().saturating_sub(48 * 3600).to_string()
3622 }
3623
3624 fn fresh_timestamp() -> String {
3625 now_unix_secs_value().saturating_sub(60).to_string()
3626 }
3627
3628 fn assert_model_cache_unavailable(
3629 result: Result<(ModelsCache, RefreshOutcome), MarsError>,
3630 reason_contains: &str,
3631 ) {
3632 match result {
3633 Err(MarsError::ModelCacheUnavailable { reason }) => {
3634 assert!(
3635 reason.contains(reason_contains),
3636 "unexpected reason: {reason}"
3637 );
3638 }
3639 other => panic!("expected ModelCacheUnavailable, got {other:?}"),
3640 }
3641 }
3642
3643 #[test]
3644 #[serial]
3645 fn ensure_fresh_1_missing_cache_offline_errors() {
3646 let mars = tempdir().unwrap();
3647 let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3648
3649 let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3650 assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
3651 }
3652
3653 #[test]
3654 #[serial]
3655 fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
3656 let mars = tempdir().unwrap();
3657 let server = MockServer::start();
3658 let mock = server.mock(|when, then| {
3659 when.method(GET).path("/api.json");
3660 then.status(500).body("server error");
3661 });
3662 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3663
3664 let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3665 assert_model_cache_unavailable(result, "automatic refresh failed");
3666 assert_eq!(mock.hits(), 1);
3667 }
3668
3669 #[test]
3670 fn ensure_fresh_3_stale_usable_offline_returns_stale() {
3671 let mars = tempdir().unwrap();
3672 write_cache_state(
3673 mars.path(),
3674 vec![sample_cached_model("stale-model")],
3675 &stale_timestamp(),
3676 );
3677
3678 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
3679 assert_eq!(cache.models.len(), 1);
3680 assert_eq!(cache.models[0].id, "stale-model");
3681 assert_eq!(outcome, RefreshOutcome::Offline);
3682 }
3683
3684 #[test]
3685 #[serial]
3686 fn ensure_fresh_4_fresh_auto_skips_http() {
3687 let mars = tempdir().unwrap();
3688 write_cache_state(
3689 mars.path(),
3690 vec![sample_cached_model("fresh-model")],
3691 &fresh_timestamp(),
3692 );
3693
3694 let server = MockServer::start();
3695 let mock = server.mock(|when, then| {
3696 when.method(GET).path("/api.json");
3697 then.status(200).json_body(sample_catalog_json());
3698 });
3699 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3700
3701 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3702 assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
3703 assert_eq!(mock.hits(), 0);
3704 }
3705
3706 #[test]
3707 #[serial]
3708 fn ensure_fresh_5_stale_auto_success_refreshes() {
3709 let mars = tempdir().unwrap();
3710 write_cache_state(
3711 mars.path(),
3712 vec![sample_cached_model("old-model")],
3713 &stale_timestamp(),
3714 );
3715
3716 let server = MockServer::start();
3717 let mock = server.mock(|when, then| {
3718 when.method(GET).path("/api.json");
3719 then.status(200).json_body(sample_catalog_json());
3720 });
3721 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3722
3723 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3724 assert!(matches!(
3725 outcome,
3726 RefreshOutcome::Refreshed { models_count } if models_count == 2
3727 ));
3728 assert_eq!(cache.models.len(), 2);
3729 assert!(!cache.models.is_empty());
3730 assert!(cache.fetched_at.is_some());
3731 assert_eq!(mock.hits(), 1);
3732 }
3733
3734 #[test]
3735 #[serial]
3736 fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
3737 let mars = tempdir().unwrap();
3738 write_cache_state(
3739 mars.path(),
3740 vec![sample_cached_model("stale-model")],
3741 &stale_timestamp(),
3742 );
3743
3744 let server = MockServer::start();
3745 let mock = server.mock(|when, then| {
3746 when.method(GET).path("/api.json");
3747 then.status(500).body("server error");
3748 });
3749 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3750
3751 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3752 assert_eq!(cache.models[0].id, "stale-model");
3753 assert!(matches!(
3754 outcome,
3755 RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
3756 ));
3757 assert_eq!(mock.hits(), 1);
3758 }
3759
3760 #[test]
3761 #[serial]
3762 fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
3763 let mars = tempdir().unwrap();
3764 write_cache_state(
3765 mars.path(),
3766 vec![sample_cached_model("stale-model")],
3767 &stale_timestamp(),
3768 );
3769
3770 let server = MockServer::start();
3771 let mock = server.mock(|when, then| {
3772 when.method(GET).path("/api.json");
3773 then.status(200).json_body(serde_json::json!({}));
3774 });
3775 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3776
3777 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3778 assert_eq!(cache.models[0].id, "stale-model");
3779 assert!(matches!(
3780 outcome,
3781 RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
3782 ));
3783 assert_eq!(mock.hits(), 1);
3784 }
3785
3786 #[test]
3787 #[serial]
3788 fn ensure_fresh_8_empty_cache_auto_refetches() {
3789 let mars = tempdir().unwrap();
3790 write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
3791
3792 let server = MockServer::start();
3793 let mock = server.mock(|when, then| {
3794 when.method(GET).path("/api.json");
3795 then.status(200).json_body(sample_catalog_json());
3796 });
3797 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3798
3799 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3800 assert!(!cache.models.is_empty());
3801 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3802 assert_eq!(mock.hits(), 1);
3803 }
3804
3805 #[test]
3806 fn ensure_fresh_9_empty_cache_offline_errors() {
3807 let mars = tempdir().unwrap();
3808 write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
3809
3810 let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
3811 assert_model_cache_unavailable(result, "--no-refresh-models was passed");
3812 }
3813
3814 #[test]
3815 #[serial]
3816 fn ensure_fresh_10_corrupt_json_auto_refetches() {
3817 let mars = tempdir().unwrap();
3818 write_raw_cache_file(mars.path(), "{ not-json ");
3819
3820 let server = MockServer::start();
3821 let mock = server.mock(|when, then| {
3822 when.method(GET).path("/api.json");
3823 then.status(200).json_body(sample_catalog_json());
3824 });
3825 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3826
3827 let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3828 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3829 assert!(!cache.models.is_empty());
3830 assert_eq!(mock.hits(), 1);
3831 }
3832
3833 #[test]
3834 fn ensure_fresh_11_corrupt_json_offline_errors() {
3835 let mars = tempdir().unwrap();
3836 write_raw_cache_file(mars.path(), "{ not-json ");
3837
3838 let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
3839 assert_model_cache_unavailable(result, "--no-refresh-models was passed");
3840 }
3841
3842 #[test]
3843 fn read_cache_io_error_includes_operation_and_path() {
3844 let mars = tempdir().unwrap();
3845 let cache_path = mars.path().join(CACHE_FILE);
3846 std::fs::create_dir(&cache_path).unwrap();
3847
3848 let err = read_cache(mars.path()).unwrap_err();
3849 let msg = err.to_string();
3850
3851 assert!(
3852 msg.contains("read models cache"),
3853 "error should include operation context: {msg}"
3854 );
3855 assert!(
3856 msg.contains(CACHE_FILE),
3857 "error should include cache path: {msg}"
3858 );
3859 }
3860
3861 #[test]
3862 #[serial]
3863 fn ensure_fresh_12_ttl_zero_always_refetches() {
3864 let mars = tempdir().unwrap();
3865 write_cache_state(
3866 mars.path(),
3867 vec![sample_cached_model("fresh-model")],
3868 &fresh_timestamp(),
3869 );
3870
3871 let server = MockServer::start();
3872 let mock = server.mock(|when, then| {
3873 when.method(GET).path("/api.json");
3874 then.status(200).json_body(sample_catalog_json());
3875 });
3876 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3877
3878 let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
3879 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3880 assert_eq!(mock.hits(), 1);
3881 }
3882
3883 #[test]
3884 #[serial]
3885 fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
3886 let mars = tempdir().unwrap();
3887 write_cache_state(
3888 mars.path(),
3889 vec![sample_cached_model("stale-model")],
3890 "not-a-timestamp",
3891 );
3892
3893 let server = MockServer::start();
3894 let mock = server.mock(|when, then| {
3895 when.method(GET).path("/api.json");
3896 then.status(200).json_body(sample_catalog_json());
3897 });
3898 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3899
3900 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3901 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3902 assert_eq!(mock.hits(), 1);
3903 }
3904
3905 #[test]
3906 #[serial]
3907 fn ensure_fresh_14_future_fetched_at_is_stale() {
3908 let mars = tempdir().unwrap();
3909 let future = now_unix_secs_value() + 3600;
3910 write_cache_state(
3911 mars.path(),
3912 vec![sample_cached_model("future-model")],
3913 &future.to_string(),
3914 );
3915
3916 let server = MockServer::start();
3917 let mock = server.mock(|when, then| {
3918 when.method(GET).path("/api.json");
3919 then.status(200).json_body(sample_catalog_json());
3920 });
3921 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3922
3923 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3924 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3925 assert_eq!(mock.hits(), 1);
3926 }
3927
3928 #[test]
3929 #[serial]
3930 fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
3931 let mars = tempdir().unwrap();
3932 write_cache_state(
3933 mars.path(),
3934 vec![sample_cached_model("fresh-model")],
3935 &fresh_timestamp(),
3936 );
3937
3938 let server = MockServer::start();
3939 let mock = server.mock(|when, then| {
3940 when.method(GET).path("/api.json");
3941 then.status(200).json_body(sample_catalog_json());
3942 });
3943 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3944 let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3945
3946 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3947 assert_eq!(outcome, RefreshOutcome::Offline);
3948 assert_eq!(mock.hits(), 0);
3949 }
3950
3951 #[test]
3952 #[serial]
3953 fn ensure_fresh_16_offline_env_zero_is_not_offline() {
3954 let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
3955 assert!(!is_mars_offline());
3956 assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
3957 }
3958
3959 #[test]
3960 #[serial]
3961 fn ensure_fresh_17_offline_env_truthy_is_offline() {
3962 let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
3963 assert!(is_mars_offline());
3964 assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
3965 }
3966
3967 #[test]
3968 #[serial]
3969 fn ensure_fresh_18_force_ignores_offline_env() {
3970 let mars = tempdir().unwrap();
3971 let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3972
3973 let server = MockServer::start();
3974 let mock = server.mock(|when, then| {
3975 when.method(GET).path("/api.json");
3976 then.status(200).json_body(sample_catalog_json());
3977 });
3978 let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3979
3980 let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
3981 assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
3982 assert_eq!(mock.hits(), 1);
3983 }
3984
3985 #[test]
3986 #[serial]
3987 fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
3988 let mars = tempdir().unwrap();
3989 write_cache_state(
3990 mars.path(),
3991 vec![sample_cached_model("stale-model")],
3992 &stale_timestamp(),
3993 );
3994
3995 let path = Arc::new(mars.path().to_path_buf());
3996 let path_a = Arc::clone(&path);
3997 let path_b = Arc::clone(&path);
3998 let fetch_hits = Arc::new(AtomicUsize::new(0));
3999 let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
4000 let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
4001
4002 let fetch_hits_a = Arc::clone(&fetch_hits);
4003 let t1 = thread::spawn(move || {
4004 ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
4005 fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4006 fetch_started_tx.send(()).unwrap();
4007 release_fetch_rx.recv().unwrap();
4008 Ok(vec![sample_cached_model("fresh-model")])
4009 })
4010 .unwrap()
4011 .1
4012 });
4013
4014 fetch_started_rx.recv().unwrap();
4015
4016 let fetch_hits_b = Arc::clone(&fetch_hits);
4017 let t2 = thread::spawn(move || {
4018 ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
4019 fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4020 Ok(vec![sample_cached_model("unexpected-second-refresh")])
4021 })
4022 .unwrap()
4023 .1
4024 });
4025
4026 release_fetch_tx.send(()).unwrap();
4027
4028 let outcome_a = t1.join().unwrap();
4029 let outcome_b = t2.join().unwrap();
4030
4031 let outcomes = [outcome_a, outcome_b];
4032 let refreshed = outcomes
4033 .iter()
4034 .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
4035 .count();
4036 let already_fresh = outcomes
4037 .iter()
4038 .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
4039 .count();
4040
4041 assert_eq!(refreshed, 1);
4042 assert_eq!(already_fresh, 1);
4043 assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4044 }
4045
4046 #[test]
4047 #[serial]
4048 fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
4049 let mars = tempdir().unwrap();
4050 write_cache_state(
4051 mars.path(),
4052 vec![sample_cached_model("stale-model")],
4053 &stale_timestamp(),
4054 );
4055
4056 let fetch_hits = Arc::new(AtomicUsize::new(0));
4057
4058 let fetch_hits_a = Arc::clone(&fetch_hits);
4059 let (_cache_a, outcome_a) =
4060 ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4061 fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4062 Err(MarsError::Http {
4063 url: "https://example.test/api.json".to_string(),
4064 status: 500,
4065 message: "request failed with HTTP status 500".to_string(),
4066 })
4067 })
4068 .unwrap();
4069
4070 let fetch_hits_b = Arc::clone(&fetch_hits);
4071 let (_cache_b, outcome_b) =
4072 ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4073 fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4074 Ok(vec![sample_cached_model("unexpected-second-refresh")])
4075 })
4076 .unwrap();
4077
4078 assert!(matches!(
4079 outcome_a,
4080 RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
4081 ));
4082 assert_eq!(
4083 outcome_b,
4084 RefreshOutcome::StaleFallback {
4085 reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4086 }
4087 );
4088 assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4089 }
4090
4091 #[test]
4092 #[serial]
4093 fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
4094 let mars = tempdir().unwrap();
4095 write_cache_state(
4096 mars.path(),
4097 vec![sample_cached_model("stale-model")],
4098 &stale_timestamp(),
4099 );
4100
4101 let fetch_hits = Arc::new(AtomicUsize::new(0));
4102
4103 let fetch_hits_a = Arc::clone(&fetch_hits);
4104 let (_cache_a, outcome_a) =
4105 ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4106 fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4107 Ok(Vec::new())
4108 })
4109 .unwrap();
4110
4111 let fetch_hits_b = Arc::clone(&fetch_hits);
4112 let (_cache_b, outcome_b) =
4113 ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4114 fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4115 Ok(vec![sample_cached_model("unexpected-second-refresh")])
4116 })
4117 .unwrap();
4118
4119 assert!(matches!(
4120 outcome_a,
4121 RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
4122 ));
4123 assert_eq!(
4124 outcome_b,
4125 RefreshOutcome::StaleFallback {
4126 reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4127 }
4128 );
4129 assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4130 }
4131
4132 #[test]
4133 fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
4134 let project = tempdir().unwrap();
4135 let ctx = crate::types::MarsContext::for_test(
4136 project.path().to_path_buf(),
4137 project.path().join(".agents"),
4138 );
4139 assert_eq!(load_models_cache_ttl(&ctx), 24);
4140 }
4141
4142 #[test]
4143 fn load_models_cache_ttl_reads_config_value() {
4144 let project = tempdir().unwrap();
4145 std::fs::write(
4146 project.path().join("mars.toml"),
4147 "[settings]\nmodels_cache_ttl_hours = 48\n",
4148 )
4149 .unwrap();
4150 let ctx = crate::types::MarsContext::for_test(
4151 project.path().to_path_buf(),
4152 project.path().join(".agents"),
4153 );
4154 assert_eq!(load_models_cache_ttl(&ctx), 48);
4155 }
4156}