use super::builtin_models::BuiltinModelEntry;
use super::records::CustomModelRecord;
use crate::context::model_switch::ModelCapabilityMetadata;
use std::collections::HashMap;
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum CatalogError {
#[error("Custom model duplicates built-in entry: ({0}, {1})")]
DuplicateBuiltIn(String, String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct EffectiveModelEntry {
pub provider_slug: String,
pub model_id: String,
pub display_name: String,
pub context_window: Option<usize>,
pub output_limit: Option<u32>,
pub supports_tool_calls: bool,
pub supports_reasoning: bool,
pub reasoning_effort_values: Vec<String>,
pub supports_vision: bool,
pub supports_streaming: bool,
pub supported_modalities: Vec<String>,
pub unsupported_tools: Vec<String>,
pub cost_input_per_million: Option<f64>,
pub cost_output_per_million: Option<f64>,
pub is_custom: bool,
}
impl EffectiveModelEntry {
pub fn context_window_or_zero(&self) -> usize {
self.context_window.unwrap_or(0)
}
}
#[derive(Debug, Clone, Default)]
pub struct EffectiveModelCatalog {
models: HashMap<(String, String), EffectiveModelEntry>,
}
impl EffectiveModelCatalog {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, provider: &str, model: &str) -> Option<&EffectiveModelEntry> {
self.models.get(&(provider.to_string(), model.to_string()))
}
pub fn list_for_provider(&self, provider: &str) -> Vec<&EffectiveModelEntry> {
self.models
.values()
.filter(|entry| entry.provider_slug == provider)
.collect()
}
pub fn all_entries(&self) -> Vec<&EffectiveModelEntry> {
self.models.values().collect()
}
pub fn contains(&self, provider: &str, model: &str) -> bool {
self.models
.contains_key(&(provider.to_string(), model.to_string()))
}
fn register_builtin(&mut self, entry: &BuiltinModelEntry) {
let key = (entry.provider_slug.clone(), entry.model_id.clone());
self.models.insert(
key,
EffectiveModelEntry {
provider_slug: entry.provider_slug.clone(),
model_id: entry.model_id.clone(),
display_name: entry.display_name.clone(),
context_window: entry.context_window,
output_limit: entry.output_limit,
supports_tool_calls: entry.supports_tool_calls,
supports_reasoning: entry.supports_reasoning,
reasoning_effort_values: entry.reasoning_effort_values.clone(),
supports_vision: entry.supports_vision,
supports_streaming: entry.supports_streaming,
supported_modalities: entry.supported_modalities.clone(),
unsupported_tools: entry.unsupported_tools.clone(),
cost_input_per_million: None,
cost_output_per_million: None,
is_custom: false,
},
);
}
fn register_custom(&mut self, entry: &CustomModelRecord) {
let mut modalities = vec!["text".to_string()];
if entry.supports_vision {
modalities.push("image".to_string());
}
let key = (entry.provider_slug.clone(), entry.model_id.clone());
self.models.insert(
key,
EffectiveModelEntry {
provider_slug: entry.provider_slug.clone(),
model_id: entry.model_id.clone(),
display_name: entry.display_name.clone(),
context_window: entry.context_window.map(|v| v as usize),
output_limit: entry.output_limit,
supports_tool_calls: entry.supports_tool_calls,
supports_reasoning: entry.supports_reasoning
|| !entry.reasoning_effort_values.is_empty(),
reasoning_effort_values: entry.reasoning_effort_values.clone(),
supports_vision: entry.supports_vision,
supports_streaming: entry.supports_streaming,
supported_modalities: modalities,
unsupported_tools: Vec::new(),
cost_input_per_million: entry.cost_input_per_million,
cost_output_per_million: entry.cost_output_per_million,
is_custom: true,
},
);
}
}
pub fn build_effective_catalog(
builtin: &[BuiltinModelEntry],
custom: &[CustomModelRecord],
) -> Result<EffectiveModelCatalog, CatalogError> {
let mut catalog = EffectiveModelCatalog::new();
for entry in builtin {
catalog.register_builtin(entry);
}
for entry in custom {
if catalog.contains(&entry.provider_slug, &entry.model_id) {
return Err(CatalogError::DuplicateBuiltIn(
entry.provider_slug.clone(),
entry.model_id.clone(),
));
}
catalog.register_custom(entry);
}
Ok(catalog)
}
impl From<&EffectiveModelEntry> for ModelCapabilityMetadata {
fn from(entry: &EffectiveModelEntry) -> Self {
Self {
model: entry.model_id.clone(),
provider: entry.provider_slug.clone(),
context_window: entry.context_window.unwrap_or(0),
supports_tools: entry.supports_tool_calls,
supports_streaming: entry.supports_streaming,
supports_reasoning_effort: entry.supports_reasoning,
reasoning_effort_values: entry.reasoning_effort_values.clone(),
supported_modalities: entry.supported_modalities.clone(),
unsupported_tools: entry.unsupported_tools.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::super::builtin_models::BuiltinModelEntry;
use super::*;
use chrono::Utc;
fn custom_model_record(provider_slug: &str, model_id: &str) -> CustomModelRecord {
CustomModelRecord {
provider_slug: provider_slug.to_string(),
model_id: model_id.to_string(),
display_name: format!("Custom {}", model_id),
context_window: Some(100_000),
output_limit: Some(4_096),
supports_tool_calls: true,
supports_reasoning: false,
supports_vision: true,
supports_streaming: true,
reasoning_effort_values: Vec::new(),
cost_input_per_million: None,
cost_output_per_million: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_catalog_merge_built_in_and_custom() {
let builtin = vec![BuiltinModelEntry::new("openai", "gpt-4o")
.with_display_name("GPT-4o")
.with_context_window(128_000)
.with_tools()
.with_vision()];
let custom = vec![custom_model_record("custom-provider", "custom-model")];
let catalog = build_effective_catalog(&builtin, &custom).unwrap();
assert!(catalog.get("openai", "gpt-4o").is_some());
assert!(catalog.get("custom-provider", "custom-model").is_some());
}
#[test]
fn test_catalog_rejects_duplicate_built_in() {
let builtin = vec![BuiltinModelEntry::new("openai", "gpt-4o")
.with_display_name("GPT-4o")
.with_context_window(128_000)
.with_tools()];
let custom = vec![custom_model_record("openai", "gpt-4o")];
let result = build_effective_catalog(&builtin, &custom);
assert!(matches!(
result,
Err(CatalogError::DuplicateBuiltIn(ref p, ref m)) if p == "openai" && m == "gpt-4o"
));
}
#[test]
fn test_catalog_lookup_and_list_by_provider() {
let builtin = vec![
BuiltinModelEntry::new("openai", "gpt-4o").with_context_window(128_000),
BuiltinModelEntry::new("openai", "gpt-4o-mini").with_context_window(128_000),
BuiltinModelEntry::new("anthropic", "claude-3-5-sonnet-20241022")
.with_context_window(200_000),
];
let custom = vec![custom_model_record("openai", "custom-openai")];
let catalog = build_effective_catalog(&builtin, &custom).unwrap();
let openai_models = catalog.list_for_provider("openai");
assert_eq!(openai_models.len(), 3);
let anthropic_models = catalog.list_for_provider("anthropic");
assert_eq!(anthropic_models.len(), 1);
assert!(catalog.get("openai", "gpt-4o").is_some());
assert!(catalog.get("openai", "nonexistent").is_none());
}
#[test]
fn test_catalog_converts_to_capability_metadata() {
let builtin = vec![BuiltinModelEntry::new("openai", "gpt-4o")
.with_context_window(128_000)
.with_tools()
.with_vision()];
let catalog = build_effective_catalog(&builtin, &[]).unwrap();
let entry = catalog.get("openai", "gpt-4o").unwrap();
let meta: ModelCapabilityMetadata = entry.into();
assert_eq!(meta.model, "gpt-4o");
assert_eq!(meta.provider, "openai");
assert_eq!(meta.context_window, 128_000);
assert!(meta.supports_tools);
assert!(meta.supports_streaming);
assert!(meta.supported_modalities.contains(&"image".to_string()));
}
#[test]
fn test_custom_entry_inherits_modalities_from_vision_flag() {
let custom = vec![CustomModelRecord {
provider_slug: "custom".to_string(),
model_id: "vision-model".to_string(),
display_name: "Vision".to_string(),
context_window: Some(100_000),
output_limit: Some(4_096),
supports_tool_calls: true,
supports_reasoning: false,
supports_vision: true,
supports_streaming: true,
reasoning_effort_values: Vec::new(),
cost_input_per_million: None,
cost_output_per_million: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}];
let catalog = build_effective_catalog(&[], &custom).unwrap();
let entry = catalog.get("custom", "vision-model").unwrap();
assert!(entry.supported_modalities.contains(&"text".to_string()));
assert!(entry.supported_modalities.contains(&"image".to_string()));
}
}