use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::sync::OnceLock;
const MAX_MODEL_ALIASES: usize = 4096;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct ModelAliasMap {
pub entries: BTreeMap<String, String>,
}
impl<'de> Deserialize<'de> for ModelAliasMap {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let entries = match value {
serde_json::Value::Object(object) => object
.into_iter()
.filter_map(|(key, value)| match value {
serde_json::Value::String(canonical) => Some((key, canonical)),
_ => None,
})
.collect(),
_ => BTreeMap::new(),
};
Ok(Self { entries })
}
}
#[derive(Debug, Default)]
pub(crate) struct ModelAliasResolver {
map: HashMap<String, String>,
}
impl ModelAliasResolver {
pub(crate) fn from_config(config: &ModelAliasMap) -> Self {
let mut map = HashMap::new();
for (raw_alias, raw_canonical) in &config.entries {
if map.len() >= MAX_MODEL_ALIASES {
break;
}
let alias_norm = crate::normalize_syntactic(raw_alias);
let canonical = crate::normalize_syntactic(raw_canonical);
if alias_norm.is_empty() || canonical.is_empty() || alias_norm == canonical {
continue;
}
map.insert(match_key(&alias_norm), canonical);
}
Self { map }
}
pub(crate) fn apply(&self, name: String) -> String {
match self.map.get(&match_key(&name)) {
Some(canonical) => canonical.clone(),
None => name,
}
}
}
fn match_key(normalized: &str) -> String {
normalized.replace('.', "-")
}
static GLOBAL: OnceLock<ModelAliasResolver> = OnceLock::new();
static EMPTY: OnceLock<ModelAliasResolver> = OnceLock::new();
pub fn set_global(config: &ModelAliasMap) {
let _ = GLOBAL.set(ModelAliasResolver::from_config(config));
}
pub(crate) fn global() -> &'static ModelAliasResolver {
GLOBAL
.get()
.unwrap_or_else(|| EMPTY.get_or_init(ModelAliasResolver::default))
}
#[cfg(test)]
mod tests {
use super::*;
fn resolver(pairs: &[(&str, &str)]) -> ModelAliasResolver {
let entries = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
ModelAliasResolver::from_config(&ModelAliasMap { entries })
}
#[test]
fn folds_three_variants_to_one_canonical() {
let r = resolver(&[
("claude-opus-4-8-cc", "claude-opus-4-8"),
("anthropic/claude-opus-4-8", "claude-opus-4-8"),
]);
for input in [
"claude-opus-4-8-cc",
"anthropic/claude-opus-4-8",
"Claude-Opus-4-8",
] {
assert_eq!(
r.apply(crate::normalize_syntactic(input)),
"claude-opus-4-8",
"input {input} should fold to claude-opus-4-8"
);
}
}
#[test]
fn keys_match_case_and_dotted_insensitively() {
let r = resolver(&[("Claude-Opus-4.8-CC", "claude-opus-4-8")]);
assert_eq!(
r.apply(crate::normalize_syntactic("claude-opus-4-8-cc")),
"claude-opus-4-8"
);
}
#[test]
fn drops_empty_and_self_maps() {
let r = resolver(&[
("", "claude-opus-4-8"),
("claude-opus-4-8-cc", ""),
("gpt-5.5", "gpt-5.5"),
]);
assert!(r.map.is_empty());
}
#[test]
fn resolution_is_single_hop() {
let r = resolver(&[("model-a", "model-b"), ("model-b", "model-c")]);
assert_eq!(r.apply("model-a".to_string()), "model-b");
assert_eq!(r.apply("model-b".to_string()), "model-c");
}
#[test]
fn separator_insensitive_match_is_provider_agnostic() {
let dashed_key = resolver(&[("gpt-5-5-cc", "gpt-5.5")]);
assert_eq!(
dashed_key.apply(crate::normalize_syntactic("gpt-5.5-cc")),
"gpt-5.5",
"a dashed alias key must match the dotted model spelling (gpt-5-5 ↔ gpt-5.5)"
);
let dotted_key = resolver(&[("gpt-5.5-cc", "gpt-5.5")]);
assert_eq!(
dotted_key.apply(crate::normalize_syntactic("gpt-5-5-cc")),
"gpt-5.5",
"a dotted alias key must match the dashed model spelling"
);
}
#[test]
fn miss_is_identity() {
let r = resolver(&[("claude-opus-4-8-cc", "claude-opus-4-8")]);
assert_eq!(r.apply("gpt-5.5".to_string()), "gpt-5.5");
}
#[test]
fn empty_resolver_is_identity() {
let r = ModelAliasResolver::default();
assert_eq!(
r.apply("claude-opus-4-8-cc".to_string()),
"claude-opus-4-8-cc"
);
}
#[test]
fn respects_capacity_cap() {
let entries: BTreeMap<String, String> = (0..MAX_MODEL_ALIASES + 100)
.map(|i| (format!("alias-{i}"), format!("canonical-{i}")))
.collect();
let r = ModelAliasResolver::from_config(&ModelAliasMap { entries });
assert_eq!(r.map.len(), MAX_MODEL_ALIASES);
}
#[test]
fn deserialize_is_lossy_over_non_string_values() {
let parsed: ModelAliasMap =
serde_json::from_str(r#"{"a": "b", "n": 5, "arr": ["x"]}"#).unwrap();
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries.get("a").map(String::as_str), Some("b"));
}
#[test]
fn deserialize_of_non_object_is_empty() {
assert!(serde_json::from_str::<ModelAliasMap>("[]")
.unwrap()
.entries
.is_empty());
assert!(serde_json::from_str::<ModelAliasMap>("\"oops\"")
.unwrap()
.entries
.is_empty());
}
#[test]
fn serialize_round_trips_as_flat_map() {
let map = ModelAliasMap {
entries: [(
"claude-opus-4-8-cc".to_string(),
"claude-opus-4-8".to_string(),
)]
.into_iter()
.collect(),
};
let json = serde_json::to_string(&map).unwrap();
assert_eq!(json, r#"{"claude-opus-4-8-cc":"claude-opus-4-8"}"#);
assert_eq!(serde_json::from_str::<ModelAliasMap>(&json).unwrap(), map);
}
}