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