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