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