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