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