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