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