use std::collections::HashSet;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::diagnostic::DiagnosticCollector;
use crate::error::MarsError;
use crate::types::MarsContext;
pub mod harness;
mod tracing {
macro_rules! debug {
($($arg:tt)*) => {
if cfg!(debug_assertions) {
eprintln!($($arg)*);
}
};
}
pub(super) use debug;
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ModelAlias {
#[serde(skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub spec: ModelSpec,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ModelSpec {
Pinned {
model: String,
provider: Option<String>,
},
AutoResolve {
provider: String,
match_patterns: Vec<String>,
exclude_patterns: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HarnessSource {
Explicit,
AutoDetected,
Unavailable,
}
#[derive(Debug, Clone, Serialize)]
pub struct ResolvedAlias {
pub name: String,
pub model_id: String,
pub provider: String,
pub harness: Option<String>,
pub harness_source: HarnessSource,
pub harness_candidates: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl Serialize for ModelSpec {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
ModelSpec::Pinned { model, provider } => {
let mut count = 1;
if provider.is_some() {
count += 1;
}
let mut map = serializer.serialize_map(Some(count))?;
map.serialize_entry("model", model)?;
if let Some(provider) = provider {
map.serialize_entry("provider", provider)?;
}
map.end()
}
ModelSpec::AutoResolve {
provider,
match_patterns,
exclude_patterns,
} => {
let mut count = 2; if !exclude_patterns.is_empty() {
count += 1;
}
let mut map = serializer.serialize_map(Some(count))?;
map.serialize_entry("provider", provider)?;
map.serialize_entry("match", match_patterns)?;
if !exclude_patterns.is_empty() {
map.serialize_entry("exclude", exclude_patterns)?;
}
map.end()
}
}
}
}
#[derive(Debug, Deserialize)]
struct RawModelAlias {
harness: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
provider: Option<String>,
#[serde(default, rename = "match")]
match_patterns: Option<Vec<String>>,
#[serde(default)]
exclude: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for ModelAlias {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = RawModelAlias::deserialize(deserializer)?;
let has_model = raw.model.is_some();
let has_match = raw.match_patterns.is_some();
if has_model && has_match {
return Err(serde::de::Error::custom(
"model alias cannot have both 'model' and 'match' — use one or the other",
));
}
let spec = if let Some(model) = raw.model {
ModelSpec::Pinned {
model,
provider: raw.provider,
}
} else if let Some(match_patterns) = raw.match_patterns {
let provider = raw.provider.ok_or_else(|| {
serde::de::Error::custom(
"auto-resolve model alias requires 'provider' when 'match' is specified",
)
})?;
ModelSpec::AutoResolve {
provider,
match_patterns,
exclude_patterns: raw.exclude.unwrap_or_default(),
}
} else {
return Err(serde::de::Error::custom(
"model alias must have either 'model' (pinned) or 'match' (auto-resolve)",
));
};
Ok(ModelAlias {
harness: raw.harness,
description: raw.description,
spec,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelsCache {
pub models: Vec<CachedModel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetched_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedModel {
pub id: String,
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output: Option<u64>,
}
const CACHE_FILE: &str = "models-cache.json";
const FETCH_FAIL_MARKER_FILE: &str = ".models-cache.last-fail";
const DEFAULT_MODELS_CACHE_TTL_HOURS: u32 = 24;
pub(crate) const FETCH_FAIL_COOLDOWN_SECS: u64 = 300;
const FETCH_FAIL_COOLDOWN_REASON: &str = "recent fetch attempt failed; backing off (cooldown)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshMode {
Auto,
Force,
Offline,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefreshOutcome {
AlreadyFresh,
Refreshed { models_count: usize },
StaleFallback { reason: String },
Offline,
}
pub fn now_unix_secs_value() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn now_unix_secs() -> String {
now_unix_secs_value().to_string()
}
pub fn is_mars_offline() -> bool {
match std::env::var("MARS_OFFLINE") {
Ok(value) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
),
Err(_) => false,
}
}
pub fn resolve_refresh_mode(no_refresh_flag: bool) -> RefreshMode {
if no_refresh_flag {
RefreshMode::Offline
} else {
RefreshMode::Auto
}
}
pub fn load_models_cache_ttl(ctx: &MarsContext) -> u32 {
crate::config::load(&ctx.project_root)
.map(|config| config.settings.models_cache_ttl_hours)
.unwrap_or(DEFAULT_MODELS_CACHE_TTL_HOURS)
}
fn read_cache_tolerant(mars_dir: &Path) -> ModelsCache {
match read_cache(mars_dir) {
Ok(cache) => cache,
Err(err) => {
tracing::debug!("models cache read failed, treating as empty: {err}");
ModelsCache {
models: Vec::new(),
fetched_at: None,
}
}
}
}
fn is_fresh(cache: &ModelsCache, ttl_hours: u32) -> bool {
if ttl_hours == 0 {
return false;
}
if cache.models.is_empty() {
return false;
}
let Some(fetched_str) = &cache.fetched_at else {
return false;
};
let Ok(fetched) = fetched_str.parse::<u64>() else {
return false;
};
let now = now_unix_secs_value();
if fetched > now {
return false;
}
(now - fetched) < (ttl_hours as u64) * 3600
}
fn is_usable(cache: &ModelsCache) -> bool {
!cache.models.is_empty()
}
fn read_fetch_fail_marker(mars_dir: &Path) -> Option<u64> {
let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
let raw = std::fs::read_to_string(marker).ok()?;
raw.trim().parse::<u64>().ok()
}
fn write_fetch_fail_marker(mars_dir: &Path, timestamp: u64) {
let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
if let Err(err) = crate::fs::atomic_write(&marker, timestamp.to_string().as_bytes()) {
tracing::debug!("failed to write models fetch failure marker: {err}");
}
}
fn clear_fetch_fail_marker(mars_dir: &Path) {
let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
if let Err(err) = std::fs::remove_file(marker)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::debug!("failed to clear models fetch failure marker: {err}");
}
}
pub fn ensure_fresh(
mars_dir: &Path,
ttl_hours: u32,
mode: RefreshMode,
) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
ensure_fresh_with_fetcher(mars_dir, ttl_hours, mode, fetch_models)
}
fn ensure_fresh_with_fetcher<F>(
mars_dir: &Path,
ttl_hours: u32,
mode: RefreshMode,
fetcher: F,
) -> Result<(ModelsCache, RefreshOutcome), MarsError>
where
F: FnOnce() -> Result<Vec<CachedModel>, MarsError>,
{
std::fs::create_dir_all(mars_dir)?;
let effective_mode = match mode {
RefreshMode::Auto if is_mars_offline() => RefreshMode::Offline,
m => m,
};
let prior = read_cache_tolerant(mars_dir);
if effective_mode == RefreshMode::Auto && is_fresh(&prior, ttl_hours) {
return Ok((prior, RefreshOutcome::AlreadyFresh));
}
if effective_mode == RefreshMode::Offline {
if is_usable(&prior) {
return Ok((prior, RefreshOutcome::Offline));
}
return Err(MarsError::ModelCacheUnavailable {
reason: offline_unavailable_reason(mode),
});
}
let lock_path = mars_dir.join(".models-cache.lock");
let _guard = crate::fs::FileLock::acquire(&lock_path)?;
let under_lock = read_cache_tolerant(mars_dir);
if effective_mode == RefreshMode::Auto && is_fresh(&under_lock, ttl_hours) {
return Ok((under_lock, RefreshOutcome::AlreadyFresh));
}
if mode != RefreshMode::Force && is_usable(&under_lock) {
let now = now_unix_secs_value();
if let Some(last_fail) = read_fetch_fail_marker(mars_dir)
&& now.saturating_sub(last_fail) < FETCH_FAIL_COOLDOWN_SECS
{
return Ok((
under_lock,
RefreshOutcome::StaleFallback {
reason: FETCH_FAIL_COOLDOWN_REASON.to_string(),
},
));
}
}
match fetcher() {
Ok(models) if !models.is_empty() => {
let models_count = models.len();
let cache = ModelsCache {
models,
fetched_at: Some(now_unix_secs()),
};
write_cache(mars_dir, &cache)?;
clear_fetch_fail_marker(mars_dir);
Ok((cache, RefreshOutcome::Refreshed { models_count }))
}
Ok(_) => fallback_to_stale_or_error(
mars_dir,
under_lock,
"API returned empty catalog".to_string(),
"API returned an empty catalog and no prior cache exists".to_string(),
true,
),
Err(err) => fallback_to_stale_or_error(
mars_dir,
under_lock,
format!("fetch failed: {err}"),
format!("automatic refresh failed: {err}"),
true,
),
}
}
fn fallback_to_stale_or_error(
mars_dir: &Path,
under_lock: ModelsCache,
stale_reason: String,
unavailable_reason: String,
mark_fetch_failure: bool,
) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
if is_usable(&under_lock) {
if mark_fetch_failure {
write_fetch_fail_marker(mars_dir, now_unix_secs_value());
}
Ok((
under_lock,
RefreshOutcome::StaleFallback {
reason: stale_reason,
},
))
} else {
Err(MarsError::ModelCacheUnavailable {
reason: unavailable_reason,
})
}
}
fn offline_unavailable_reason(requested_mode: RefreshMode) -> String {
match requested_mode {
RefreshMode::Offline => {
"--no-refresh-models was passed and no cached catalog is available".to_string()
}
RefreshMode::Auto => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
RefreshMode::Force => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
}
}
pub fn read_cache(mars_dir: &Path) -> Result<ModelsCache, MarsError> {
let path = mars_dir.join(CACHE_FILE);
match std::fs::read_to_string(&path) {
Ok(content) => {
let cache: ModelsCache =
serde_json::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
message: format!("failed to parse models cache: {e}"),
})?;
Ok(cache)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsCache {
models: Vec::new(),
fetched_at: None,
}),
Err(e) => Err(MarsError::Io(e)),
}
}
pub fn write_cache(mars_dir: &Path, cache: &ModelsCache) -> Result<(), MarsError> {
std::fs::create_dir_all(mars_dir)?;
let path = mars_dir.join(CACHE_FILE);
let tmp_path = mars_dir.join(".models-cache.json.tmp");
let content =
serde_json::to_string_pretty(cache).map_err(|e| crate::error::ConfigError::Invalid {
message: format!("failed to serialize models cache: {e}"),
})?;
std::fs::write(&tmp_path, content)?;
std::fs::rename(&tmp_path, &path)?;
Ok(())
}
pub fn fetch_models() -> Result<Vec<CachedModel>, MarsError> {
let url = models_api_url();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(15)))
.timeout_recv_response(Some(Duration::from_secs(15)))
.timeout_recv_body(Some(Duration::from_secs(15)))
.build()
.into();
let response = agent.get(&url).call().map_err(|e| match e {
ureq::Error::StatusCode(status) => MarsError::Http {
url: url.clone(),
status,
message: format!("request failed with HTTP status {status}"),
},
_ => MarsError::Http {
url: url.clone(),
status: 0,
message: format!("failed to fetch models catalog: {e}"),
},
})?;
let body = response
.into_body()
.read_to_string()
.map_err(|e| MarsError::Http {
url: url.clone(),
status: 0,
message: format!("failed to read response body: {e}"),
})?;
let raw: serde_json::Value =
serde_json::from_str(&body).map_err(|e| crate::error::ConfigError::Invalid {
message: format!("failed to parse models API response: {e}"),
})?;
parse_models_dev_catalog(&raw)
}
fn models_api_url() -> String {
std::env::var("MARS_MODELS_API_URL").unwrap_or_else(|_| "https://models.dev/api.json".into())
}
fn parse_models_dev_catalog(raw: &serde_json::Value) -> Result<Vec<CachedModel>, MarsError> {
let providers = raw
.as_object()
.ok_or_else(|| crate::error::ConfigError::Invalid {
message: "models API response must be an object keyed by provider".to_string(),
})?;
let mut models = Vec::new();
for (provider_key, provider_obj) in providers {
if !is_major_provider(provider_key) {
continue;
}
let Some(provider_models) = provider_obj.get("models").and_then(|m| m.as_object()) else {
continue;
};
for model_obj in provider_models.values() {
let Some(model_id) = model_obj.get("id").and_then(|v| v.as_str()) else {
continue;
};
let release_date = model_obj
.get("release_date")
.and_then(|v| v.as_str())
.map(str::to_string);
let description = model_obj
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let context_window = model_obj
.get("limit")
.and_then(|v| v.get("context"))
.and_then(|v| v.as_u64());
let max_output = model_obj
.get("limit")
.and_then(|v| v.get("output"))
.and_then(|v| v.as_u64());
models.push(CachedModel {
id: model_id.to_string(),
provider: normalize_provider(provider_key),
release_date,
description,
context_window,
max_output,
});
}
}
Ok(models)
}
fn is_major_provider(provider_key: &str) -> bool {
matches!(
provider_key,
"anthropic"
| "openai"
| "google"
| "meta-llama"
| "meta"
| "mistralai"
| "mistral"
| "deepseek"
| "cohere"
)
}
fn normalize_provider(slug: &str) -> String {
match slug {
"anthropic" => "Anthropic".to_string(),
"openai" => "OpenAI".to_string(),
"google" => "Google".to_string(),
"meta-llama" | "meta" => "Meta".to_string(),
"mistralai" | "mistral" => "Mistral".to_string(),
"deepseek" => "DeepSeek".to_string(),
"cohere" => "Cohere".to_string(),
_ => slug.to_string(),
}
}
pub fn auto_resolve(
provider: &str,
match_patterns: &[String],
exclude_patterns: &[String],
cache: &ModelsCache,
) -> Option<String> {
let mut candidates: Vec<&CachedModel> = cache
.models
.iter()
.filter(|m| {
m.provider.eq_ignore_ascii_case(provider)
})
.filter(|m| {
!m.id.ends_with("-latest")
})
.filter(|m| {
match_patterns.iter().all(|p| glob_match(p, &m.id))
})
.filter(|m| {
!exclude_patterns.iter().any(|p| glob_match(p, &m.id))
})
.collect();
candidates.sort_by(|a, b| {
let date_cmp = b
.release_date
.as_deref()
.unwrap_or("")
.cmp(a.release_date.as_deref().unwrap_or(""));
date_cmp.then_with(|| a.id.len().cmp(&b.id.len()))
});
candidates.first().map(|m| m.id.clone())
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
let segments: Vec<&str> = pattern.split('*').collect();
if segments.len() == 1 {
return pattern == text;
}
let mut pos = 0;
if let Some(first) = segments.first()
&& !first.is_empty()
{
if !text.starts_with(first) {
return false;
}
pos = first.len();
}
if let Some(last) = segments.last()
&& !last.is_empty()
&& !text[pos..].ends_with(last)
{
return false;
}
let end = if let Some(last) = segments.last() {
if !last.is_empty() {
text.len() - last.len()
} else {
text.len()
}
} else {
text.len()
};
for segment in &segments[1..segments.len().saturating_sub(1)] {
if segment.is_empty() {
continue;
}
if let Some(idx) = text[pos..end].find(segment) {
pos += idx + segment.len();
} else {
return false;
}
}
pos <= end
}
pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
let mut m = IndexMap::new();
let add = |m: &mut IndexMap<String, ModelAlias>,
name: &str,
provider: &str,
match_patterns: &[&str],
exclude: &[&str]| {
m.insert(
name.to_string(),
ModelAlias {
harness: None,
description: None,
spec: ModelSpec::AutoResolve {
provider: provider.to_string(),
match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
},
},
);
};
add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
add(
&mut m,
"codex",
"openai",
&["*codex*"],
&["*-mini", "*-spark", "*-max"],
);
add(
&mut m,
"gpt",
"openai",
&["gpt-5*"],
&["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
);
add(
&mut m,
"gemini",
"google",
&["gemini*", "*pro*"],
&["*-customtools"],
);
m
}
pub struct ResolvedDepModels {
pub source_name: String,
pub models: IndexMap<String, ModelAlias>,
}
pub fn merge_model_config(
consumer: &IndexMap<String, ModelAlias>,
deps: &[ResolvedDepModels],
diag: &mut DiagnosticCollector,
) -> IndexMap<String, ModelAlias> {
let mut merged = IndexMap::new();
let builtins = builtin_aliases();
for (name, alias) in &builtins {
merged.insert(name.clone(), alias.clone());
}
let mut dep_provided: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for dep in deps {
for (name, alias) in &dep.models {
if consumer.contains_key(name) {
continue;
}
if let Some(winner) = dep_provided.get(name) {
diag.warn_with_context(
"model-alias-conflict",
format!(
"model alias `{name}` defined by both `{winner}` and `{}` — using {winner} (declared first)\n → add [models.{name}] to your mars.toml to resolve explicitly",
dep.source_name
),
dep.source_name.clone(),
);
} else {
merged.insert(name.clone(), alias.clone());
dep_provided.insert(name.clone(), dep.source_name.clone());
}
}
}
for (name, alias) in consumer {
merged.insert(name.clone(), alias.clone());
}
merged
}
pub fn resolve_all(
aliases: &IndexMap<String, ModelAlias>,
cache: &ModelsCache,
) -> IndexMap<String, ResolvedAlias> {
let installed = harness::detect_installed_harnesses();
let mut resolved = IndexMap::new();
for (name, alias) in aliases {
let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
continue; };
let candidates = harness::harness_candidates_for_provider(&provider);
let (h, source) = resolve_harness(alias, &provider, &installed);
resolved.insert(
name.clone(),
ResolvedAlias {
name: name.clone(),
model_id,
provider,
harness: h,
harness_source: source,
harness_candidates: candidates,
description: alias.description.clone(),
},
);
}
resolved
}
pub fn filter_by_visibility(
mut aliases: IndexMap<String, ResolvedAlias>,
visibility: &crate::config::ModelVisibility,
) -> IndexMap<String, ResolvedAlias> {
if let Some(includes) = &visibility.include {
aliases.retain(|name, _| includes.iter().any(|p| glob_match(p, name)));
} else if let Some(excludes) = &visibility.exclude {
aliases.retain(|name, _| !excludes.iter().any(|p| glob_match(p, name)));
}
aliases
}
fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
match &alias.spec {
ModelSpec::Pinned { model, provider } => {
let p = provider
.clone()
.or_else(|| infer_provider_from_model_id(model).map(str::to_string))
.unwrap_or_else(|| "unknown".to_string());
Some((model.clone(), p))
}
ModelSpec::AutoResolve {
provider,
match_patterns,
exclude_patterns,
} => {
let id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
Some((id, provider.clone()))
}
}
}
fn resolve_harness(
alias: &ModelAlias,
provider: &str,
installed: &HashSet<String>,
) -> (Option<String>, HarnessSource) {
if let Some(h) = &alias.harness {
if installed.contains(h) {
(Some(h.clone()), HarnessSource::Explicit)
} else {
(Some(h.clone()), HarnessSource::Unavailable)
}
} else {
match harness::resolve_harness_for_provider(provider, installed) {
Some(h) => (Some(h), HarnessSource::AutoDetected),
None => (None, HarnessSource::Unavailable),
}
}
}
#[allow(dead_code)]
fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
let id = model_id.to_lowercase();
if id.starts_with("claude-") {
return Some("anthropic");
}
if id.starts_with("gpt-")
|| id.starts_with("o1")
|| id.starts_with("o3")
|| id.starts_with("o4")
|| id.starts_with("codex-")
{
return Some("openai");
}
if id.starts_with("gemini") {
return Some("google");
}
if id.starts_with("llama") {
return Some("meta");
}
if id.starts_with("mistral") || id.starts_with("codestral") {
return Some("mistral");
}
if id.starts_with("deepseek") {
return Some("deepseek");
}
if id.starts_with("command") {
return Some("cohere");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;
use std::collections::HashSet;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use tempfile::tempdir;
use serial_test::serial;
#[test]
fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
let raw = serde_json::json!({
"anthropic": {
"models": {
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"release_date": "2026-02-05",
"limit": {
"context": 1000000,
"output": 128000
}
}
}
},
"openai": {
"models": {
"gpt-5": {
"id": "gpt-5",
"name": "GPT-5"
}
}
},
"random-host": {
"models": {
"foo": {
"id": "foo"
}
}
}
});
let models = parse_models_dev_catalog(&raw).unwrap();
assert_eq!(models.len(), 2);
let opus = models
.iter()
.find(|m| m.id == "claude-opus-4-6")
.expect("missing claude-opus-4-6");
assert_eq!(opus.provider, "Anthropic");
assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
assert_eq!(opus.context_window, Some(1_000_000));
assert_eq!(opus.max_output, Some(128_000));
let gpt = models
.iter()
.find(|m| m.id == "gpt-5")
.expect("missing gpt-5");
assert_eq!(gpt.provider, "OpenAI");
assert_eq!(gpt.release_date, None);
assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
assert_eq!(gpt.context_window, None);
assert_eq!(gpt.max_output, None);
}
#[test]
fn parse_models_dev_catalog_requires_object_root() {
let raw = serde_json::json!(["not", "an", "object"]);
let err = parse_models_dev_catalog(&raw).unwrap_err();
assert!(err.to_string().contains("keyed by provider"));
}
#[test]
fn glob_exact_match() {
assert!(glob_match("claude-opus-4", "claude-opus-4"));
assert!(!glob_match("claude-opus-4", "claude-opus-5"));
}
#[test]
fn glob_star_suffix() {
assert!(glob_match("claude-opus-*", "claude-opus-4"));
assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
}
#[test]
fn glob_star_prefix() {
assert!(glob_match("*-opus-4", "claude-opus-4"));
assert!(!glob_match("*-opus-4", "claude-opus-5"));
}
#[test]
fn glob_star_middle() {
assert!(glob_match("claude-*-4", "claude-opus-4"));
assert!(glob_match("claude-*-4", "claude-sonnet-4"));
assert!(!glob_match("claude-*-4", "claude-opus-5"));
}
#[test]
fn glob_multiple_stars() {
assert!(glob_match("*claude*opus*", "claude-opus-4"));
assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
}
#[test]
fn glob_star_only() {
assert!(glob_match("*", "anything"));
assert!(glob_match("*", ""));
}
#[test]
fn glob_empty_pattern() {
assert!(glob_match("", ""));
assert!(!glob_match("", "something"));
}
fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
ModelsCache {
models: models
.into_iter()
.map(|(id, provider, date)| CachedModel {
id: id.to_string(),
provider: provider.to_string(),
release_date: date.map(String::from),
description: None,
context_window: None,
max_output: None,
})
.collect(),
fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
}
}
#[test]
fn auto_resolve_basic() {
let cache = make_cache(vec![
("claude-opus-4", "Anthropic", Some("2025-03-01")),
("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
]);
let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
}
#[test]
fn auto_resolve_exclude() {
let cache = make_cache(vec![
("gpt-5", "OpenAI", Some("2025-06-01")),
("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
]);
let result = auto_resolve(
"OpenAI",
&["gpt-*".to_string()],
&["gpt-3*".to_string(), "gpt-4o*".to_string()],
&cache,
);
assert_eq!(result, Some("gpt-5".to_string()));
}
#[test]
fn auto_resolve_skip_latest() {
let cache = make_cache(vec![
("claude-opus-latest", "Anthropic", Some("9999-01-01")),
("claude-opus-4", "Anthropic", Some("2025-03-01")),
]);
let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
assert_eq!(result, Some("claude-opus-4".to_string()));
}
#[test]
fn auto_resolve_empty_cache() {
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
assert_eq!(result, None);
}
#[test]
fn auto_resolve_no_match() {
let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
assert_eq!(result, None);
}
#[test]
fn auto_resolve_provider_case_insensitive() {
let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
assert_eq!(result, Some("claude-opus-4".to_string()));
}
#[test]
fn auto_resolve_shortest_id_tiebreaker() {
let cache = make_cache(vec![
("claude-opus-4", "Anthropic", Some("2025-03-01")),
("claude-opus-4x", "Anthropic", Some("2025-03-01")),
]);
let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
assert_eq!(result, Some("claude-opus-4".to_string()));
}
fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
ModelAlias {
harness: harness.map(|h| h.to_string()),
description: None,
spec: ModelSpec::Pinned {
model: model.to_string(),
provider: None,
},
}
}
#[test]
fn merge_empty_returns_builtins() {
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&IndexMap::new(), &[], &mut diag);
assert!(merged.contains_key("opus"));
assert!(merged.contains_key("sonnet"));
assert!(merged.contains_key("codex"));
}
#[test]
fn merge_consumer_overrides_dependency_alias() {
let mut consumer = IndexMap::new();
consumer.insert(
"opus".to_string(),
pinned_alias(Some("custom"), "my-opus-model"),
);
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&consumer, &[], &mut diag);
assert_eq!(
merged.get("opus").unwrap().spec,
ModelSpec::Pinned {
model: "my-opus-model".to_string(),
provider: None
}
);
}
#[test]
fn merge_dep_overrides_builtin() {
let dep = ResolvedDepModels {
source_name: "my-pkg".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
m
},
};
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag);
assert_eq!(
merged.get("opus").unwrap().spec,
ModelSpec::Pinned {
model: "pkg-opus".to_string(),
provider: None
}
);
}
#[test]
fn merge_consumer_beats_dep() {
let mut consumer = IndexMap::new();
consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
let dep = ResolvedDepModels {
source_name: "pkg".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
m
},
};
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&consumer, &[dep], &mut diag);
assert_eq!(
merged.get("opus").unwrap().spec,
ModelSpec::Pinned {
model: "consumer-opus".to_string(),
provider: None
}
);
}
#[test]
fn merge_dep_conflict_warns_with_winner_and_resolution_hint() {
let dep1 = ResolvedDepModels {
source_name: "pkg-a".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
m
},
};
let dep2 = ResolvedDepModels {
source_name: "pkg-b".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
m
},
};
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
assert_eq!(
merged.get("custom").unwrap().spec,
ModelSpec::Pinned {
model: "model-a".to_string(),
provider: None
}
);
let warnings = diag.drain();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code, "model-alias-conflict");
assert_eq!(
warnings[0].message,
"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"
);
}
#[test]
fn merge_dep_three_way_conflict_warns_each_loser_against_first_winner() {
let dep1 = ResolvedDepModels {
source_name: "pkg-a".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
m
},
};
let dep2 = ResolvedDepModels {
source_name: "pkg-b".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
m
},
};
let dep3 = ResolvedDepModels {
source_name: "pkg-c".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("c"), "model-c"));
m
},
};
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2, dep3], &mut diag);
assert_eq!(
merged.get("custom").unwrap().spec,
ModelSpec::Pinned {
model: "model-a".to_string(),
provider: None
}
);
let warnings = diag.drain();
assert_eq!(warnings.len(), 2);
assert_eq!(
warnings[0].message,
"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"
);
assert_eq!(
warnings[1].message,
"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"
);
}
#[test]
fn merge_consumer_override_suppresses_dep_conflict_warning() {
let mut consumer = IndexMap::new();
consumer.insert(
"custom".to_string(),
pinned_alias(Some("consumer"), "consumer-model"),
);
let dep1 = ResolvedDepModels {
source_name: "pkg-a".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
m
},
};
let dep2 = ResolvedDepModels {
source_name: "pkg-b".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
m
},
};
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&consumer, &[dep1, dep2], &mut diag);
assert_eq!(
merged.get("custom").unwrap().spec,
ModelSpec::Pinned {
model: "consumer-model".to_string(),
provider: None
}
);
assert!(diag.drain().is_empty());
}
#[test]
fn merge_dep_conflicts_are_non_blocking() {
let dep1 = ResolvedDepModels {
source_name: "pkg-a".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
m
},
};
let dep2 = ResolvedDepModels {
source_name: "pkg-b".to_string(),
models: {
let mut m = IndexMap::new();
m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
m.insert("extra".to_string(), pinned_alias(Some("b"), "model-extra"));
m
},
};
let mut diag = DiagnosticCollector::new();
let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
assert!(merged.contains_key("opus"));
assert_eq!(
merged.get("custom").unwrap().spec,
ModelSpec::Pinned {
model: "model-a".to_string(),
provider: None
}
);
assert_eq!(
merged.get("extra").unwrap().spec,
ModelSpec::Pinned {
model: "model-extra".to_string(),
provider: None
}
);
assert_eq!(diag.drain().len(), 1);
}
#[test]
fn resolve_all_pinned() {
let mut aliases = IndexMap::new();
aliases.insert(
"fast".to_string(),
pinned_alias(Some("claude"), "claude-haiku-4-5"),
);
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_all(&aliases, &cache);
let entry = resolved.get("fast").unwrap();
assert_eq!(entry.model_id, "claude-haiku-4-5");
assert_eq!(entry.provider, "anthropic");
}
#[test]
fn resolve_all_pinned_with_provider() {
let mut aliases = IndexMap::new();
aliases.insert(
"fast".to_string(),
ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "gpt-5.3-codex".to_string(),
provider: Some("openai".to_string()),
},
},
);
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_all(&aliases, &cache);
let entry = resolved.get("fast").unwrap();
assert_eq!(entry.model_id, "gpt-5.3-codex");
assert_eq!(entry.provider, "openai");
assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
}
#[test]
fn resolve_all_pinned_auto_detect_harness() {
let mut aliases = IndexMap::new();
aliases.insert(
"opus".to_string(),
ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: Some("anthropic".to_string()),
},
},
);
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_all(&aliases, &cache);
let entry = resolved.get("opus").unwrap();
assert_eq!(entry.model_id, "claude-opus-4-6");
assert_eq!(entry.provider, "anthropic");
let installed = harness::detect_installed_harnesses();
let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
let expected_source = if expected_harness.is_some() {
HarnessSource::AutoDetected
} else {
HarnessSource::Unavailable
};
assert_eq!(entry.harness, expected_harness);
assert_eq!(entry.harness_source, expected_source);
}
#[test]
fn resolve_all_auto_detect_harness() {
let mut aliases = IndexMap::new();
aliases.insert(
"gpt".to_string(),
ModelAlias {
harness: None,
description: None,
spec: ModelSpec::AutoResolve {
provider: "openai".to_string(),
match_patterns: vec!["gpt-5*".to_string()],
exclude_patterns: vec![],
},
},
);
let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
let resolved = resolve_all(&aliases, &cache);
let entry = resolved.get("gpt").unwrap();
assert_eq!(entry.model_id, "gpt-5");
assert_eq!(entry.provider, "openai");
assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
match entry.harness_source {
HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
HarnessSource::Unavailable => assert!(entry.harness.is_none()),
HarnessSource::Explicit => panic!("unexpected explicit harness source"),
}
}
#[test]
fn resolve_all_unavailable_harness_still_included() {
let mut aliases = IndexMap::new();
aliases.insert(
"opus".to_string(),
ModelAlias {
harness: Some("missing-harness-xyz".to_string()),
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: None,
},
},
);
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_all(&aliases, &cache);
let entry = resolved.get("opus").unwrap();
assert_eq!(entry.model_id, "claude-opus-4-6");
assert_eq!(entry.provider, "anthropic");
assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
assert_eq!(entry.harness_source, HarnessSource::Unavailable);
}
#[test]
fn resolve_all_empty_cache_omits_unresolvable() {
let mut aliases = IndexMap::new();
aliases.insert(
"opus".to_string(),
ModelAlias {
harness: Some("claude".to_string()),
description: None,
spec: ModelSpec::AutoResolve {
provider: "Anthropic".to_string(),
match_patterns: vec!["claude-opus-*".to_string()],
exclude_patterns: vec![],
},
},
);
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_all(&aliases, &cache);
assert!(!resolved.contains_key("opus"));
}
fn make_resolved_alias(name: &str) -> ResolvedAlias {
ResolvedAlias {
name: name.to_string(),
model_id: format!("model-{name}"),
provider: "openai".to_string(),
harness: Some("codex".to_string()),
harness_source: HarnessSource::Explicit,
harness_candidates: vec!["codex".to_string()],
description: None,
}
}
#[test]
fn filter_by_visibility_include_mode_keeps_matches_only() {
let mut aliases = IndexMap::new();
aliases.insert("opus".to_string(), make_resolved_alias("opus"));
aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
let filtered = filter_by_visibility(
aliases,
&crate::config::ModelVisibility {
include: Some(vec!["opus*".to_string(), "gpt-*".to_string()]),
exclude: None,
},
);
assert_eq!(filtered.len(), 2);
assert!(filtered.contains_key("opus"));
assert!(filtered.contains_key("gpt-5"));
assert!(!filtered.contains_key("sonnet"));
}
#[test]
fn filter_by_visibility_exclude_mode_removes_matches() {
let mut aliases = IndexMap::new();
aliases.insert("opus".to_string(), make_resolved_alias("opus"));
aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
aliases.insert(
"deprecated-gpt".to_string(),
make_resolved_alias("deprecated-gpt"),
);
let filtered = filter_by_visibility(
aliases,
&crate::config::ModelVisibility {
include: None,
exclude: Some(vec!["test-*".to_string(), "deprecated-*".to_string()]),
},
);
assert_eq!(filtered.len(), 1);
assert!(filtered.contains_key("opus"));
assert!(!filtered.contains_key("test-opus"));
assert!(!filtered.contains_key("deprecated-gpt"));
}
#[test]
fn filter_by_visibility_empty_config_returns_all() {
let mut aliases = IndexMap::new();
aliases.insert("opus".to_string(), make_resolved_alias("opus"));
aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
assert_eq!(filtered.len(), 2);
assert!(filtered.contains_key("opus"));
assert!(filtered.contains_key("sonnet"));
}
#[test]
fn resolve_model_and_provider_pinned_explicit_provider() {
let alias = ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: Some("anthropic".to_string()),
},
};
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
assert_eq!(
resolved,
("claude-opus-4-6".to_string(), "anthropic".to_string())
);
}
#[test]
fn resolve_model_and_provider_pinned_inferred() {
let alias = ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: None,
},
};
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
assert_eq!(
resolved,
("claude-opus-4-6".to_string(), "anthropic".to_string())
);
}
#[test]
fn resolve_model_and_provider_pinned_unknown() {
let alias = ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "my-custom-model".to_string(),
provider: None,
},
};
let cache = ModelsCache {
models: Vec::new(),
fetched_at: None,
};
let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
assert_eq!(
resolved,
("my-custom-model".to_string(), "unknown".to_string())
);
}
#[test]
fn resolve_model_and_provider_auto_resolve() {
let alias = ModelAlias {
harness: None,
description: None,
spec: ModelSpec::AutoResolve {
provider: "openai".to_string(),
match_patterns: vec!["gpt-5*".to_string()],
exclude_patterns: vec![],
},
};
let cache = make_cache(vec![
("gpt-4o", "OpenAI", Some("2024-06-01")),
("gpt-5", "OpenAI", Some("2025-06-01")),
]);
let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
}
#[test]
fn resolve_harness_explicit_installed() {
let alias = ModelAlias {
harness: Some("claude".to_string()),
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: None,
},
};
let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
let resolved = resolve_harness(&alias, "anthropic", &installed);
assert_eq!(
resolved,
(Some("claude".to_string()), HarnessSource::Explicit)
);
}
#[test]
fn resolve_harness_explicit_not_installed() {
let alias = ModelAlias {
harness: Some("claude".to_string()),
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: None,
},
};
let installed = HashSet::new();
let resolved = resolve_harness(&alias, "anthropic", &installed);
assert_eq!(
resolved,
(Some("claude".to_string()), HarnessSource::Unavailable)
);
}
#[test]
fn resolve_harness_auto_detected() {
let alias = ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: Some("anthropic".to_string()),
},
};
let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
let resolved = resolve_harness(&alias, "anthropic", &installed);
assert_eq!(
resolved,
(Some("claude".to_string()), HarnessSource::AutoDetected)
);
}
#[test]
fn resolve_harness_unavailable() {
let alias = ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "claude-opus-4-6".to_string(),
provider: Some("anthropic".to_string()),
},
};
let installed = HashSet::new();
let resolved = resolve_harness(&alias, "anthropic", &installed);
assert_eq!(resolved, (None, HarnessSource::Unavailable));
}
#[test]
fn resolve_harness_unavailable_no_provider_match() {
let alias = ModelAlias {
harness: None,
description: None,
spec: ModelSpec::Pinned {
model: "my-custom-model".to_string(),
provider: Some("unknown".to_string()),
},
};
let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
let resolved = resolve_harness(&alias, "unknown", &installed);
assert_eq!(resolved, (None, HarnessSource::Unavailable));
}
#[test]
fn harness_source_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&HarnessSource::Explicit).unwrap(),
"\"explicit\""
);
assert_eq!(
serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
"\"auto_detected\""
);
assert_eq!(
serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
"\"unavailable\""
);
}
#[test]
fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
let toml_str = r#"
[models.fast]
harness = "claude"
model = "claude-haiku-4-5"
description = "Fast and cheap"
"#;
#[derive(Debug, Deserialize)]
struct Wrapper {
models: IndexMap<String, ModelAlias>,
}
let parsed: Wrapper = toml::from_str(toml_str).unwrap();
let alias = parsed.models.get("fast").unwrap();
assert_eq!(
alias.spec,
ModelSpec::Pinned {
model: "claude-haiku-4-5".to_string(),
provider: None
}
);
assert_eq!(alias.harness.as_deref(), Some("claude"));
assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
let json = serde_json::to_string(alias).unwrap();
let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped, *alias);
}
#[test]
fn model_alias_pinned_toml_roundtrip_without_harness() {
let toml_str = r#"
[models.fast]
model = "claude-haiku-4-5"
"#;
#[derive(Debug, Deserialize)]
struct Wrapper {
models: IndexMap<String, ModelAlias>,
}
let parsed: Wrapper = toml::from_str(toml_str).unwrap();
let alias = parsed.models.get("fast").unwrap();
assert_eq!(alias.harness, None);
assert_eq!(
alias.spec,
ModelSpec::Pinned {
model: "claude-haiku-4-5".to_string(),
provider: None
}
);
let json = serde_json::to_string(alias).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value.get("harness").is_none());
assert!(value.get("provider").is_none());
let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped, *alias);
}
#[test]
fn model_alias_pinned_toml_roundtrip_with_provider() {
let toml_str = r#"
[models.fast]
model = "claude-haiku-4-5"
provider = "anthropic"
"#;
#[derive(Debug, Deserialize)]
struct Wrapper {
models: IndexMap<String, ModelAlias>,
}
let parsed: Wrapper = toml::from_str(toml_str).unwrap();
let alias = parsed.models.get("fast").unwrap();
assert_eq!(alias.harness, None);
assert_eq!(
alias.spec,
ModelSpec::Pinned {
model: "claude-haiku-4-5".to_string(),
provider: Some("anthropic".to_string())
}
);
let json = serde_json::to_string(alias).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
value.get("provider").and_then(serde_json::Value::as_str),
Some("anthropic")
);
let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped, *alias);
}
#[test]
fn model_alias_pinned_json_roundtrip_with_provider() {
let json = r#"{
"model": "gpt-5.3-codex",
"provider": "openai"
}"#;
let alias: ModelAlias = serde_json::from_str(json).unwrap();
assert_eq!(alias.harness, None);
assert_eq!(alias.description, None);
assert_eq!(
alias.spec,
ModelSpec::Pinned {
model: "gpt-5.3-codex".to_string(),
provider: Some("openai".to_string())
}
);
let encoded = serde_json::to_string(&alias).unwrap();
let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
assert_eq!(roundtripped, alias);
}
#[test]
fn model_alias_auto_resolve_toml_roundtrip() {
let toml_str = r#"
[models.opus]
harness = "claude"
provider = "Anthropic"
match = ["claude-opus-*"]
exclude = ["claude-opus-3*"]
description = "Best reasoning"
"#;
#[derive(Debug, Deserialize)]
struct Wrapper {
models: IndexMap<String, ModelAlias>,
}
let parsed: Wrapper = toml::from_str(toml_str).unwrap();
let alias = parsed.models.get("opus").unwrap();
assert_eq!(alias.harness.as_deref(), Some("claude"));
match &alias.spec {
ModelSpec::AutoResolve {
provider,
match_patterns,
exclude_patterns,
} => {
assert_eq!(provider, "Anthropic");
assert_eq!(match_patterns, &["claude-opus-*"]);
assert_eq!(exclude_patterns, &["claude-opus-3*"]);
}
_ => panic!("expected AutoResolve"),
}
}
#[test]
fn model_alias_both_model_and_match_errors() {
let toml_str = r#"
[models.bad]
harness = "claude"
model = "some-model"
match = ["pattern-*"]
"#;
#[derive(Debug, Deserialize)]
struct Wrapper {
#[expect(dead_code)]
models: IndexMap<String, ModelAlias>,
}
let result = toml::from_str::<Wrapper>(toml_str);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("both"));
}
#[test]
fn model_alias_neither_model_nor_match_errors() {
let toml_str = r#"
[models.bad]
harness = "claude"
"#;
#[derive(Debug, Deserialize)]
struct Wrapper {
#[expect(dead_code)]
models: IndexMap<String, ModelAlias>,
}
let result = toml::from_str::<Wrapper>(toml_str);
assert!(result.is_err());
}
#[test]
fn infer_provider_from_model_id_detects_known_prefixes() {
assert_eq!(
infer_provider_from_model_id("claude-opus-4-6"),
Some("anthropic")
);
assert_eq!(
infer_provider_from_model_id("gpt-5.3-codex"),
Some("openai")
);
assert_eq!(
infer_provider_from_model_id("gemini-2.5-pro"),
Some("google")
);
assert_eq!(
infer_provider_from_model_id("llama-4-maverick"),
Some("meta")
);
assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
assert_eq!(
infer_provider_from_model_id("codex-mini-latest"),
Some("openai")
);
assert_eq!(
infer_provider_from_model_id("mistral-large"),
Some("mistral")
);
assert_eq!(
infer_provider_from_model_id("codestral-latest"),
Some("mistral")
);
assert_eq!(
infer_provider_from_model_id("deepseek-chat"),
Some("deepseek")
);
assert_eq!(
infer_provider_from_model_id("command-r-plus"),
Some("cohere")
);
}
#[test]
fn infer_provider_from_model_id_returns_none_for_unknown_model() {
assert_eq!(infer_provider_from_model_id("unknown-model"), None);
}
#[test]
fn infer_provider_from_model_id_returns_none_for_empty_string() {
assert_eq!(infer_provider_from_model_id(""), None);
}
#[test]
fn infer_provider_from_model_id_is_case_insensitive() {
assert_eq!(
infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
Some("anthropic")
);
assert_eq!(
infer_provider_from_model_id("GPT-5.3-codex"),
Some("openai")
);
assert_eq!(
infer_provider_from_model_id("CoDeStRaL-latest"),
Some("mistral")
);
}
#[allow(unused_unsafe)]
fn env_set(key: &str, value: &str) {
unsafe {
std::env::set_var(key, value);
}
}
#[allow(unused_unsafe)]
fn env_remove(key: &str) {
unsafe {
std::env::remove_var(key);
}
}
struct EnvVarGuard {
key: String,
prev: Option<String>,
}
impl EnvVarGuard {
fn set(key: &str, value: &str) -> Self {
let prev = std::env::var(key).ok();
env_set(key, value);
Self {
key: key.to_string(),
prev,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(prev) = &self.prev {
env_set(&self.key, prev);
} else {
env_remove(&self.key);
}
}
}
fn sample_catalog_json() -> serde_json::Value {
serde_json::json!({
"openai": {
"models": {
"gpt-5": {
"id": "gpt-5",
"name": "GPT-5",
"release_date": "2025-06-01",
"limit": {
"context": 400000,
"output": 128000
}
}
}
},
"anthropic": {
"models": {
"claude-sonnet-4-5": {
"id": "claude-sonnet-4-5",
"name": "Claude Sonnet 4.5",
"release_date": "2025-03-01"
}
}
}
})
}
fn sample_cached_model(id: &str) -> CachedModel {
CachedModel {
id: id.to_string(),
provider: "OpenAI".to_string(),
release_date: None,
description: None,
context_window: None,
max_output: None,
}
}
fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
write_cache(
mars_dir,
&ModelsCache {
models,
fetched_at: Some(fetched_at.to_string()),
},
)
.expect("failed to write cache fixture");
}
fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
}
fn stale_timestamp() -> String {
now_unix_secs_value().saturating_sub(48 * 3600).to_string()
}
fn fresh_timestamp() -> String {
now_unix_secs_value().saturating_sub(60).to_string()
}
fn assert_model_cache_unavailable(
result: Result<(ModelsCache, RefreshOutcome), MarsError>,
reason_contains: &str,
) {
match result {
Err(MarsError::ModelCacheUnavailable { reason }) => {
assert!(
reason.contains(reason_contains),
"unexpected reason: {reason}"
);
}
other => panic!("expected ModelCacheUnavailable, got {other:?}"),
}
}
#[test]
#[serial]
fn ensure_fresh_1_missing_cache_offline_errors() {
let mars = tempdir().unwrap();
let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
}
#[test]
#[serial]
fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
let mars = tempdir().unwrap();
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(500).body("server error");
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
assert_model_cache_unavailable(result, "automatic refresh failed");
assert_eq!(mock.hits(), 1);
}
#[test]
fn ensure_fresh_3_stale_usable_offline_returns_stale() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("stale-model")],
&stale_timestamp(),
);
let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
assert_eq!(cache.models.len(), 1);
assert_eq!(cache.models[0].id, "stale-model");
assert_eq!(outcome, RefreshOutcome::Offline);
}
#[test]
#[serial]
fn ensure_fresh_4_fresh_auto_skips_http() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("fresh-model")],
&fresh_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
assert_eq!(mock.hits(), 0);
}
#[test]
#[serial]
fn ensure_fresh_5_stale_auto_success_refreshes() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("old-model")],
&stale_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert!(matches!(
outcome,
RefreshOutcome::Refreshed { models_count } if models_count == 2
));
assert_eq!(cache.models.len(), 2);
assert!(!cache.models.is_empty());
assert!(cache.fetched_at.is_some());
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("stale-model")],
&stale_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(500).body("server error");
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert_eq!(cache.models[0].id, "stale-model");
assert!(matches!(
outcome,
RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
));
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("stale-model")],
&stale_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(serde_json::json!({}));
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert_eq!(cache.models[0].id, "stale-model");
assert!(matches!(
outcome,
RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
));
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_8_empty_cache_auto_refetches() {
let mars = tempdir().unwrap();
write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert!(!cache.models.is_empty());
assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
assert_eq!(mock.hits(), 1);
}
#[test]
fn ensure_fresh_9_empty_cache_offline_errors() {
let mars = tempdir().unwrap();
write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
assert_model_cache_unavailable(result, "--no-refresh-models was passed");
}
#[test]
#[serial]
fn ensure_fresh_10_corrupt_json_auto_refetches() {
let mars = tempdir().unwrap();
write_raw_cache_file(mars.path(), "{ not-json ");
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
assert!(!cache.models.is_empty());
assert_eq!(mock.hits(), 1);
}
#[test]
fn ensure_fresh_11_corrupt_json_offline_errors() {
let mars = tempdir().unwrap();
write_raw_cache_file(mars.path(), "{ not-json ");
let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
assert_model_cache_unavailable(result, "--no-refresh-models was passed");
}
#[test]
#[serial]
fn ensure_fresh_12_ttl_zero_always_refetches() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("fresh-model")],
&fresh_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("stale-model")],
"not-a-timestamp",
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_14_future_fetched_at_is_stale() {
let mars = tempdir().unwrap();
let future = now_unix_secs_value() + 3600;
write_cache_state(
mars.path(),
vec![sample_cached_model("future-model")],
&future.to_string(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("fresh-model")],
&fresh_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert_eq!(outcome, RefreshOutcome::Offline);
assert_eq!(mock.hits(), 0);
}
#[test]
#[serial]
fn ensure_fresh_16_offline_env_zero_is_not_offline() {
let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
assert!(!is_mars_offline());
assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
}
#[test]
#[serial]
fn ensure_fresh_17_offline_env_truthy_is_offline() {
let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
assert!(is_mars_offline());
assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
}
#[test]
#[serial]
fn ensure_fresh_18_force_ignores_offline_env() {
let mars = tempdir().unwrap();
let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(sample_catalog_json());
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("stale-model")],
&stale_timestamp(),
);
let path = Arc::new(mars.path().to_path_buf());
let path_a = Arc::clone(&path);
let path_b = Arc::clone(&path);
let fetch_hits = Arc::new(AtomicUsize::new(0));
let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
let fetch_hits_a = Arc::clone(&fetch_hits);
let t1 = thread::spawn(move || {
ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
fetch_hits_a.fetch_add(1, Ordering::SeqCst);
fetch_started_tx.send(()).unwrap();
release_fetch_rx.recv().unwrap();
Ok(vec![sample_cached_model("fresh-model")])
})
.unwrap()
.1
});
fetch_started_rx.recv().unwrap();
let fetch_hits_b = Arc::clone(&fetch_hits);
let t2 = thread::spawn(move || {
ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
fetch_hits_b.fetch_add(1, Ordering::SeqCst);
Ok(vec![sample_cached_model("unexpected-second-refresh")])
})
.unwrap()
.1
});
release_fetch_tx.send(()).unwrap();
let outcome_a = t1.join().unwrap();
let outcome_b = t2.join().unwrap();
let outcomes = [outcome_a, outcome_b];
let refreshed = outcomes
.iter()
.filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
.count();
let already_fresh = outcomes
.iter()
.filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
.count();
assert_eq!(refreshed, 1);
assert_eq!(already_fresh, 1);
assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
}
#[test]
#[serial]
fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("stale-model")],
&stale_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(500).body("server error");
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert!(matches!(
outcome_a,
RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
));
assert_eq!(
outcome_b,
RefreshOutcome::StaleFallback {
reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
}
);
assert_eq!(mock.hits(), 1);
}
#[test]
#[serial]
fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
let mars = tempdir().unwrap();
write_cache_state(
mars.path(),
vec![sample_cached_model("stale-model")],
&stale_timestamp(),
);
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api.json");
then.status(200).json_body(serde_json::json!({
"openai": {
"models": {}
}
}));
});
let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
assert!(matches!(
outcome_a,
RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
));
assert_eq!(
outcome_b,
RefreshOutcome::StaleFallback {
reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
}
);
assert_eq!(mock.hits(), 1);
}
#[test]
fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
let project = tempdir().unwrap();
let ctx = crate::types::MarsContext::for_test(
project.path().to_path_buf(),
project.path().join(".agents"),
);
assert_eq!(load_models_cache_ttl(&ctx), 24);
}
#[test]
fn load_models_cache_ttl_reads_config_value() {
let project = tempdir().unwrap();
std::fs::write(
project.path().join("mars.toml"),
"[settings]\nmodels_cache_ttl_hours = 48\n",
)
.unwrap();
let ctx = crate::types::MarsContext::for_test(
project.path().to_path_buf(),
project.path().join(".agents"),
);
assert_eq!(load_models_cache_ttl(&ctx), 48);
}
}