use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModelInfo {
pub id: String,
pub provider: String,
pub context_window: usize,
pub max_output_tokens: usize,
#[serde(default)]
pub supports_tools: bool,
#[serde(default)]
pub supports_vision: bool,
#[serde(default)]
pub supports_reasoning: bool,
#[serde(default)]
pub supports_reasoning_effort: bool,
}
impl ModelInfo {
pub fn new(
provider: impl Into<String>,
id: impl Into<String>,
context_window: usize,
max_output_tokens: usize,
) -> Self {
Self {
id: id.into(),
provider: provider.into(),
context_window,
max_output_tokens,
supports_tools: false,
supports_vision: false,
supports_reasoning: false,
supports_reasoning_effort: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompatConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens_field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_role: Option<SystemRoleHandling>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools_field: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SystemRoleHandling {
TopLevel,
PrependUser,
SystemParam,
}
#[derive(Debug, Clone, Default)]
pub struct ModelRegistry {
models: HashMap<String, ModelInfo>,
compat: HashMap<String, CompatConfig>,
}
impl ModelRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn from_models<I>(models: I) -> Self
where
I: IntoIterator<Item = ModelInfo>,
{
let mut registry = Self::new();
registry.extend(models);
registry
}
pub fn register(&mut self, model: ModelInfo) -> Option<ModelInfo> {
self.models
.insert(model_key(&model.provider, &model.id), model)
}
pub fn extend<I>(&mut self, models: I)
where
I: IntoIterator<Item = ModelInfo>,
{
for model in models {
self.register(model);
}
}
pub fn register_compat(
&mut self,
provider: impl Into<String>,
config: CompatConfig,
) -> Option<CompatConfig> {
self.compat.insert(provider.into(), config)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
let file = serde_json::from_str::<OverridesFile>(json)?;
let mut registry = ModelRegistry::from_models(file.models);
registry.compat = file.compat;
Ok(registry)
}
pub fn get(&self, id: &str) -> Option<&ModelInfo> {
if let Some(model) = self.models.get(id) {
return Some(model);
}
let mut matches = self.models.values().filter(|model| model.id == id);
let first = matches.next()?;
if matches.next().is_none() {
Some(first)
} else {
None
}
}
pub fn get_for_provider(&self, provider: &str, id: &str) -> Option<&ModelInfo> {
self.models.get(&model_key(provider, id))
}
pub fn models(&self) -> impl Iterator<Item = &ModelInfo> {
self.models.values()
}
pub fn is_empty(&self) -> bool {
self.models.is_empty()
}
pub fn compat(&self, provider: &str) -> Option<&CompatConfig> {
self.compat.get(provider)
}
pub fn supports_xhigh(&self, id: &str) -> bool {
self.get(id)
.is_some_and(|model| model.supports_reasoning && is_xhigh_model(&model.id))
}
pub fn is_reasoning_model(&self, id: &str) -> bool {
self.get(id).is_some_and(|model| model.supports_reasoning)
}
pub fn supports_reasoning_effort(&self, id: &str) -> bool {
self.get(id)
.is_some_and(|model| model.supports_reasoning_effort)
}
pub fn supports_reasoning_effort_for(&self, provider: &str, id: &str) -> bool {
self.get_for_provider(provider, id)
.or_else(|| self.get(id))
.is_some_and(|model| model.supports_reasoning_effort)
}
pub fn thinking_level_clamp(&self, id: &str, requested: &str) -> String {
if !self.is_reasoning_model(id) {
return "low".into();
}
match requested {
"xhigh" if self.supports_xhigh(id) => "xhigh".into(),
"xhigh" => "high".into(),
"high" => "high".into(),
"medium" => "medium".into(),
_ => "low".into(),
}
}
}
fn model_key(provider: &str, id: &str) -> String {
format!("{provider}/{id}")
}
fn is_xhigh_model(id: &str) -> bool {
id.starts_with("gpt-5")
|| (id.starts_with("o1") && !id.contains("mini"))
|| id.starts_with("o3")
|| matches!(
id,
"claude-3-5-sonnet" | "claude-3-7-sonnet" | "claude-sonnet-4"
)
}
#[derive(Debug, Deserialize, Default)]
struct OverridesFile {
#[serde(default)]
models: Vec<ModelInfo>,
#[serde(default)]
compat: HashMap<String, CompatConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
fn model(provider: &str, id: &str) -> ModelInfo {
let mut model = ModelInfo::new(provider, id, 128_000, 8_192);
model.supports_tools = true;
model.supports_reasoning = true;
model.supports_reasoning_effort = true;
model
}
#[test]
fn registry_starts_without_rotary_owned_models() {
assert!(ModelRegistry::new().is_empty());
}
#[test]
fn consumer_metadata_controls_lookup_and_capabilities() {
let registry = ModelRegistry::from_models([model("openrouter", "openai/gpt-4o")]);
assert_eq!(
registry
.get_for_provider("openrouter", "openai/gpt-4o")
.unwrap()
.context_window,
128_000
);
assert!(registry.supports_reasoning_effort_for("openrouter", "openai/gpt-4o"));
}
#[test]
fn duplicate_model_ids_require_provider_qualification() {
let registry = ModelRegistry::from_models([
ModelInfo::new("openai", "shared", 1, 1),
ModelInfo::new("anthropic", "shared", 2, 2),
]);
assert!(registry.get("shared").is_none());
assert_eq!(
registry
.get_for_provider("anthropic", "shared")
.unwrap()
.context_window,
2
);
}
#[test]
fn compatibility_is_consumer_supplied() {
let mut registry = ModelRegistry::new();
registry.register_compat(
"custom",
CompatConfig {
max_tokens_field: Some("output_limit".into()),
..Default::default()
},
);
assert_eq!(
registry
.compat("custom")
.unwrap()
.max_tokens_field
.as_deref(),
Some("output_limit")
);
assert!(registry.compat("openai").is_none());
}
#[test]
fn json_parsing_does_not_add_defaults() {
let registry = ModelRegistry::from_json(
r#"{"models":[{"id":"live-model","provider":"custom","context_window":42,"max_output_tokens":7}]}"#,
)
.unwrap();
assert_eq!(registry.models().count(), 1);
assert!(registry.get("gpt-4o").is_none());
}
}