use std::collections::HashSet;
use std::path::Path;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::diagnostic::DiagnosticCollector;
use crate::error::MarsError;
pub mod harness;
#[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";
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 = "https://models.dev/api.json";
let response = ureq::get(url).call().map_err(|e| MarsError::Http {
url: url.to_string(),
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.to_string(),
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 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::HashSet<String> = std::collections::HashSet::new();
for dep in deps {
for (name, alias) in &dep.models {
if consumer.contains_key(name) {
continue;
}
if dep_provided.contains(name) {
diag.warn_with_context(
"model-alias-conflict",
format!(
"model alias `{name}` defined by both `{}` and earlier dependency — using earlier definition",
dep.source_name
),
dep.source_name.clone(),
);
} else {
merged.insert(name.clone(), alias.clone());
dep_provided.insert(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 std::collections::HashSet;
#[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() {
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");
}
#[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")
);
}
}