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