use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ModelTier {
Fast,
#[default]
Standard,
Reasoning,
}
impl std::fmt::Display for ModelTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ModelTier::Fast => write!(f, "Fast"),
ModelTier::Standard => write!(f, "Standard"),
ModelTier::Reasoning => write!(f, "Reasoning"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelEntry {
pub model: String,
#[serde(default = "default_context")]
pub context: u64,
#[serde(default)]
pub tier: ModelTier,
#[serde(default)]
pub pricing: Option<PricingPair>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricingPair {
pub input: f64,
pub output: f64,
#[serde(default)]
pub cache_read: f64,
#[serde(default)]
pub cache_write: f64,
}
fn default_context() -> u64 {
128_000
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderEntry {
#[serde(default)]
pub label: String,
#[serde(default)]
pub default_model: Option<String>,
pub models: Vec<ModelEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CatalogData {
pub providers: BTreeMap<String, ProviderEntry>,
}
const EMBEDDED_CATALOG_YAML: &str = include_str!("model_catalog_default.yaml");
static CATALOG: OnceLock<RwLock<CatalogData>> = OnceLock::new();
pub struct ModelCatalog;
impl ModelCatalog {
pub fn get() -> std::sync::RwLockReadGuard<'static, CatalogData> {
let lock = CATALOG.get_or_init(|| {
let data = Self::load_merged();
RwLock::new(data)
});
lock.read().unwrap_or_else(|e| e.into_inner())
}
pub fn reload() {
if let Some(lock) = CATALOG.get() {
let data = Self::load_merged();
if let Ok(mut guard) = lock.write() {
*guard = data;
}
}
}
fn load_merged() -> CatalogData {
let mut catalog: CatalogData =
serde_yml::from_str(EMBEDDED_CATALOG_YAML).unwrap_or_else(|e| {
tracing::error!(error = %e, "failed to parse embedded model catalog");
CatalogData::default()
});
let user_path = user_catalog_path();
if user_path.exists() {
match std::fs::read_to_string(&user_path) {
Ok(content) => match serde_yml::from_str::<CatalogData>(&content) {
Ok(user_data) => {
merge_catalogs(&mut catalog, &user_data);
tracing::debug!(path = %user_path.display(), "merged user model catalog");
}
Err(e) => {
tracing::warn!(
path = %user_path.display(),
error = %e,
"failed to parse user model catalog, using defaults"
);
}
},
Err(e) => {
tracing::debug!(path = %user_path.display(), error = %e, "no user model catalog");
}
}
}
catalog
}
pub fn provider_ids() -> Vec<String> {
let cat = Self::get();
cat.providers.keys().cloned().collect()
}
pub fn models_for_provider(provider: &str) -> Vec<(String, String)> {
let cat = Self::get();
cat.providers
.get(provider)
.map(|p| {
p.models
.iter()
.map(|m| {
let display = format!("{provider}/{}", m.model);
(display, m.model.clone())
})
.collect()
})
.unwrap_or_default()
}
pub fn flat_catalog() -> Vec<(String, String, String)> {
let cat = Self::get();
let mut result = Vec::new();
for (pid, entry) in &cat.providers {
for m in &entry.models {
let display = format!("{pid}/{}", m.model);
result.push((display, pid.clone(), m.model.clone()));
}
}
result
}
pub fn grouped_catalog() -> Vec<(String, Vec<String>)> {
let cat = Self::get();
cat.providers
.iter()
.map(|(pid, entry)| {
let models: Vec<String> = entry.models.iter().map(|m| m.model.clone()).collect();
(pid.clone(), models)
})
.collect()
}
pub fn default_model_for(provider: &str) -> Option<String> {
let cat = Self::get();
let entry = cat.providers.get(provider)?;
if let Some(ref dm) = entry.default_model {
Some(format!("{provider}/{dm}"))
} else {
entry
.models
.first()
.map(|m| format!("{provider}/{}", m.model))
}
}
pub fn context_window(provider: &str, model: &str) -> Option<u64> {
let cat = Self::get();
cat.providers.get(provider).and_then(|p| {
p.models
.iter()
.find(|m| m.model == model)
.map(|m| m.context)
})
}
pub fn pricing_for(provider: &str, model: &str) -> Option<PricingPair> {
let cat = Self::get();
cat.providers.get(provider).and_then(|p| {
p.models
.iter()
.find(|m| m.model == model)
.and_then(|m| m.pricing.clone())
})
}
pub fn provider_label(provider: &str) -> String {
let cat = Self::get();
cat.providers
.get(provider)
.map(|p| {
if p.label.is_empty() {
provider.to_string()
} else {
p.label.clone()
}
})
.unwrap_or_else(|| provider.to_string())
}
}
fn merge_catalogs(base: &mut CatalogData, user: &CatalogData) {
for (pid, user_entry) in &user.providers {
let base_entry = base
.providers
.entry(pid.clone())
.or_insert_with(|| ProviderEntry {
label: String::new(),
default_model: None,
models: Vec::new(),
});
if !user_entry.label.is_empty() {
base_entry.label = user_entry.label.clone();
}
if user_entry.default_model.is_some() {
base_entry.default_model = user_entry.default_model.clone();
}
for um in &user_entry.models {
if let Some(bm) = base_entry.models.iter_mut().find(|m| m.model == um.model) {
*bm = um.clone();
} else {
base_entry.models.push(um.clone());
}
}
}
}
fn user_catalog_path() -> PathBuf {
crate::config::edgecrab_home().join("models.yaml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_catalog_parses() {
let cat: CatalogData =
serde_yml::from_str(EMBEDDED_CATALOG_YAML).expect("embedded catalog YAML must parse");
assert!(!cat.providers.is_empty(), "catalog must have providers");
assert!(cat.providers.contains_key("anthropic"), "missing anthropic");
assert!(cat.providers.contains_key("bedrock"), "missing bedrock");
assert!(cat.providers.contains_key("copilot"), "missing copilot");
assert!(cat.providers.contains_key("openai"), "missing openai");
}
#[test]
fn singleton_loads() {
let cat = ModelCatalog::get();
assert!(!cat.providers.is_empty());
}
#[test]
fn flat_catalog_not_empty() {
let flat = ModelCatalog::flat_catalog();
assert!(!flat.is_empty());
}
#[test]
fn default_model_resolution() {
let dm = ModelCatalog::default_model_for("copilot");
assert!(dm.is_some(), "copilot should have a default model");
let dm = dm.expect("checked");
assert!(dm.starts_with("copilot/"));
}
#[test]
fn merge_adds_new_provider() {
let mut base = CatalogData::default();
let mut user = CatalogData::default();
user.providers.insert(
"custom-test".into(),
ProviderEntry {
label: "Test".into(),
default_model: Some("test-model".into()),
models: vec![ModelEntry {
model: "test-model".into(),
context: 8000,
tier: ModelTier::Fast,
pricing: None,
}],
},
);
merge_catalogs(&mut base, &user);
assert!(base.providers.contains_key("custom-test"));
}
#[test]
fn merge_overrides_existing_model() {
let mut base = CatalogData::default();
base.providers.insert(
"test".into(),
ProviderEntry {
label: "".into(),
default_model: None,
models: vec![ModelEntry {
model: "m1".into(),
context: 1000,
tier: ModelTier::Standard,
pricing: None,
}],
},
);
let mut user = CatalogData::default();
user.providers.insert(
"test".into(),
ProviderEntry {
label: "Override".into(),
default_model: None,
models: vec![ModelEntry {
model: "m1".into(),
context: 99999,
tier: ModelTier::Reasoning,
pricing: None,
}],
},
);
merge_catalogs(&mut base, &user);
let entry = &base.providers["test"];
assert_eq!(entry.label, "Override");
assert_eq!(entry.models[0].context, 99999);
}
}