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