mod row;
mod spec;
use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde::Deserialize;
use crate::providers::registry::{Format, ProviderId};
use row::Row;
#[doc(hidden)]
pub use spec::Compat;
pub use spec::{CacheSupport, Modalities, ModelSpec, Pricing, ReasoningSupport, Sampling};
const BUILTIN: &str = include_str!("models.json");
#[derive(Clone, Debug, Default)]
pub struct Catalog {
entries: Vec<Entry>,
}
#[derive(Clone, Debug)]
struct Entry {
row: Row,
spec: ModelSpec,
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("not a models.dev-style catalog: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Deserialize)]
struct Section {
#[serde(default)]
models: BTreeMap<String, Row>,
}
const KEYS: [(&str, &str); 9] = [
("azure", "azure.openai"),
("google", "gcp.gemini"),
("google-vertex", "vertexai"),
("amazon-bedrock", "aws_bedrock"),
("togetherai", "together"),
("moonshotai", "moonshot"),
("xiaomi", "xiaomimimo"),
("ollama-cloud", "ollama"),
("github-copilot", "copilot"),
];
impl Catalog {
pub fn builtin() -> &'static Catalog {
static BUILTIN_CATALOG: OnceLock<Catalog> = OnceLock::new();
BUILTIN_CATALOG.get_or_init(|| Catalog::from_json(BUILTIN).unwrap_or_default())
}
pub fn get(&self, provider: ProviderId, model: &str) -> Option<&ModelSpec> {
self.exact(provider.vendor(), model)
}
pub fn resolve(&self, reference: &str) -> Option<&ModelSpec> {
let (vendor, model) = split_reference(reference)?;
self.exact(vendor, model)
}
pub fn iter(&self) -> impl Iterator<Item = &ModelSpec> {
self.entries.iter().map(|entry| &entry.spec)
}
pub fn from_json(json: &str) -> Result<Catalog, CatalogError> {
let sections: BTreeMap<String, Section> = serde_json::from_str(json)?;
let mut catalog = Catalog::default();
for (key, section) in sections {
let Some(provider) = ProviderId::catalog(vendor_of(&key)) else {
continue;
};
for (id, row) in section.models {
catalog.put(provider, &id, row);
}
}
Ok(catalog)
}
pub fn merge(mut self, overrides: Catalog) -> Catalog {
for entry in overrides.entries {
self.put(entry.spec.provider, &entry.spec.id, entry.row);
}
self
}
pub fn find(&self, provider: ProviderId, model: &str) -> Option<&ModelSpec> {
self.find_vendor(provider.vendor(), model)
}
pub(crate) fn find_vendor(&self, vendor: &str, model: &str) -> Option<&ModelSpec> {
self.exact(vendor, model)
.or_else(|| self.exact(vendor, undated(model)?))
}
fn exact(&self, vendor: &str, model: &str) -> Option<&ModelSpec> {
self.position(vendor, model)
.ok()
.and_then(|index| self.entries.get(index))
.map(|entry| &entry.spec)
}
fn position(&self, vendor: &str, model: &str) -> Result<usize, usize> {
self.entries.binary_search_by(|entry| {
(entry.spec.provider.vendor(), entry.spec.id.as_str()).cmp(&(vendor, model))
})
}
fn put(&mut self, provider: ProviderId, id: &str, row: Row) {
match self.position(provider.vendor(), id) {
Ok(index) => {
if let Some(entry) = self.entries.get_mut(index) {
let row = std::mem::take(&mut entry.row).overlay(row);
entry.spec = row.spec(entry.spec.provider, id);
entry.row = row;
}
}
Err(index) => {
let spec = row.spec(provider, id);
self.entries.insert(index, Entry { row, spec });
}
}
}
}
pub(crate) fn lookup(vendor: &str, model: &str) -> Option<&'static ModelSpec> {
Catalog::builtin().find_vendor(vendor, model)
}
pub(crate) fn lookup_snapshot(vendor: &str, model: &str) -> Option<&'static ModelSpec> {
let catalog = Catalog::builtin();
catalog.exact(vendor, model).or_else(|| {
model
.match_indices("-20")
.filter_map(|(at, _)| catalog.exact(vendor, model.get(..at)?))
.last()
})
}
pub(crate) fn reads_images_or(vendor: &str, model: &str, rule: impl FnOnce(&str) -> bool) -> bool {
lookup(vendor, model).map_or_else(|| rule(model), |spec| spec.input.image)
}
fn vendor_of(key: &str) -> &str {
KEYS.iter()
.find_map(|(models_dev, vendor)| (*models_dev == key).then_some(*vendor))
.unwrap_or(key)
}
pub(crate) fn split_reference(reference: &str) -> Option<(&str, &str)> {
if let Some((selection, model)) = reference.split_once(':') {
let vendor = match selection.split_once('/') {
None => Some(selection),
Some((vendor, format)) => Format::named(format).map(|_| vendor),
};
if let Some(vendor) = vendor.filter(|vendor| !vendor.is_empty()) {
return (!model.is_empty()).then_some((vendor, model));
}
}
reference
.split_once('/')
.filter(|(vendor, model)| !vendor.is_empty() && !model.is_empty())
}
fn undated(model: &str) -> Option<&str> {
let digits = |text: &str| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit());
let (rest, last) = model.rsplit_once('-')?;
if last.len() == 8 && digits(last) {
return Some(rest);
}
let (rest, month) = rest.rsplit_once('-')?;
let (rest, year) = rest.rsplit_once('-')?;
(year.len() == 4 && month.len() == 2 && last.len() == 2)
.then_some(())
.filter(|()| digits(year) && digits(month) && digits(last))
.map(|()| rest)
}
#[cfg(test)]
mod tests;