Skip to main content

aither_models/
registry.rs

1//! Model registry with indexed lookup.
2
3use std::collections::HashMap;
4use std::sync::OnceLock;
5
6use aither_core::llm::model::Ability;
7
8use crate::convert;
9use crate::tier::{ChatPriceBreakpoints, ModelTier, classify_entry};
10use crate::types::{ModelEntry, ModelMode, Pricing, Provider};
11
12// The registry table below is emitted by `build.rs` from the LiteLLM snapshot.
13// It is machine-written and never edited by hand; the generator applies its own
14// `#[allow]` attributes because style lints on generated text can only be
15// "fixed" by changing the generator's formatting.
16include!(concat!(env!("OUT_DIR"), "/generated.rs"));
17
18/// A searchable collection of model entries.
19#[derive(Debug)]
20pub struct ModelRegistry {
21    entries: Vec<ModelEntry>,
22    index: HashMap<String, usize>,
23    chat_price_breakpoints: Option<ChatPriceBreakpoints>,
24}
25
26impl ModelRegistry {
27    /// Returns the compile-time bundled registry.
28    #[must_use]
29    pub fn bundled() -> &'static Self {
30        static INSTANCE: OnceLock<ModelRegistry> = OnceLock::new();
31        INSTANCE.get_or_init(|| {
32            let entries: Vec<ModelEntry> = ENTRIES.to_vec();
33            Self::from_entries(entries)
34        })
35    }
36
37    /// Build a registry from parsed `LiteLLM` JSON bytes.
38    #[must_use]
39    pub fn from_litellm_json(json: &[u8]) -> Self {
40        let converted = convert::parse_litellm_json(json);
41        let entries = converted.into_iter().filter_map(convert_to_entry).collect();
42        Self::from_entries(entries)
43    }
44
45    fn from_entries(entries: Vec<ModelEntry>) -> Self {
46        let mut index = HashMap::with_capacity(entries.len() * 2);
47        // Canonical IDs index first-party records only. Unprefixed keys
48        // (`claude-sonnet-4-5`) come first so a bare query resolves the
49        // vendor's own record rather than whichever hosted variant
50        // (`github_copilot/claude-sonnet-4`, `bedrock/…`) appears first.
51        for (i, entry) in entries.iter().enumerate() {
52            if entry.litellm_id() == entry.id() {
53                index.entry(entry.id().to_lowercase()).or_insert(i);
54            }
55        }
56        for (i, entry) in entries.iter().enumerate() {
57            if is_first_party(entry) {
58                index.entry(entry.id().to_lowercase()).or_insert(i);
59            }
60        }
61        // Every entry stays reachable by its full `LiteLLM` key.
62        for (i, entry) in entries.iter().enumerate() {
63            index.entry(entry.litellm_id().to_lowercase()).or_insert(i);
64        }
65        let chat_price_breakpoints = ChatPriceBreakpoints::compute(entries.iter());
66        Self {
67            entries,
68            index,
69            chat_price_breakpoints,
70        }
71    }
72
73    /// Capability/cost tier of a model, classified against this registry's
74    /// chat-model price distribution. Unknown models classify as
75    /// [`ModelTier::Balanced`].
76    #[must_use]
77    pub fn tier(&self, model_id: &str) -> ModelTier {
78        self.lookup(model_id)
79            .map_or(ModelTier::Balanced, |entry| self.tier_of(entry))
80    }
81
82    /// Capability/cost tier of a specific registry entry.
83    #[must_use]
84    pub fn tier_of(&self, entry: &ModelEntry) -> ModelTier {
85        classify_entry(entry, self.chat_price_breakpoints)
86    }
87
88    /// Exact match on the `LiteLLM` key, then version-suffix normalization.
89    ///
90    /// Normalization strips trailing `-vN[:M]`, `-YYYYMMDD`, `-YYYY-MM-DD`,
91    /// and `@YYYYMMDD` suffixes, so dated or versioned aliases resolve to
92    /// the base model's record. It never binds a bare query to a
93    /// provider-scoped key (`github_copilot/claude-sonnet-4` only answers
94    /// `github_copilot/` lookups) and never changes the model family: an
95    /// unknown or retired ID returns `None`.
96    #[must_use]
97    pub fn lookup(&self, model_id: &str) -> Option<&ModelEntry> {
98        let mut id = model_id.to_lowercase();
99        loop {
100            if let Some(&idx) = self.index.get(id.as_str()) {
101                return Some(&self.entries[idx]);
102            }
103            let stripped = strip_version_suffix(&id)?;
104            id = stripped.to_owned();
105        }
106    }
107
108    /// All models from a given provider.
109    pub fn models_for_provider(&self, provider: &Provider) -> impl Iterator<Item = &ModelEntry> {
110        let provider = provider.clone();
111        self.entries
112            .iter()
113            .filter(move |e| *e.provider() == provider)
114    }
115
116    /// All models matching a given mode.
117    pub fn models_by_mode(&self, mode: ModelMode) -> impl Iterator<Item = &ModelEntry> {
118        self.entries.iter().filter(move |e| e.mode() == mode)
119    }
120
121    /// All models with a specific ability.
122    pub fn models_with_ability(&self, ability: Ability) -> impl Iterator<Item = &ModelEntry> {
123        self.entries.iter().filter(move |e| e.has_ability(ability))
124    }
125
126    /// Iterate over all entries.
127    pub fn all(&self) -> impl Iterator<Item = &ModelEntry> {
128        self.entries.iter()
129    }
130
131    /// Total number of entries.
132    #[must_use]
133    pub const fn len(&self) -> usize {
134        self.entries.len()
135    }
136
137    /// Whether the registry is empty.
138    #[must_use]
139    pub const fn is_empty(&self) -> bool {
140        self.entries.is_empty()
141    }
142}
143
144/// `LiteLLM` prefixes under which a vendor publishes its own models
145/// (`gemini/gemini-2.5-flash` is Google's record for its own model).
146/// Hosted variants — `azure/`, `bedrock/`, `github_copilot/`, … — are
147/// reachable by their full key but do not claim the bare canonical ID.
148const FIRST_PARTY_PREFIXES: &[&str] = &[
149    "anthropic/",
150    "cohere/",
151    "cohere_chat/",
152    "dashscope/",
153    "deepseek/",
154    "gemini/",
155    "meta-llama/",
156    "mistral/",
157    "openai/",
158    "xai/",
159];
160
161/// Whether an entry is a first-party record allowed to claim the bare
162/// canonical ID: either its key carries no provider prefix at all, or the
163/// prefix is the vendor's own namespace.
164fn is_first_party(entry: &ModelEntry) -> bool {
165    entry.litellm_id() == entry.id()
166        || FIRST_PARTY_PREFIXES
167            .iter()
168            .any(|prefix| entry.litellm_id().starts_with(prefix))
169}
170
171/// Strip one trailing version suffix from a model ID: `-vN` / `-vN:M`
172/// (Bedrock), `-YYYYMMDD` / `-YYYY-MM-DD` (dated snapshots), or `@YYYYMMDD`
173/// (Vertex). Returns the base ID, or `None` when no suffix is recognized.
174fn strip_version_suffix(id: &str) -> Option<&str> {
175    let bytes = id.as_bytes();
176    let n = bytes.len();
177    if let Some((base, suffix)) = id.rsplit_once('-')
178        && let Some(rest) = suffix.strip_prefix('v')
179        && rest
180            .split(':')
181            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
182    {
183        return Some(base);
184    }
185    if n > 9 && matches!(bytes[n - 9], b'-' | b'@') && bytes[n - 8..].iter().all(u8::is_ascii_digit)
186    {
187        return Some(&id[..n - 9]);
188    }
189    if n > 11
190        && bytes[n - 11] == b'-'
191        && bytes[n - 6] == b'-'
192        && bytes[n - 3] == b'-'
193        && bytes[n - 10..n - 6].iter().all(u8::is_ascii_digit)
194        && bytes[n - 5..n - 3].iter().all(u8::is_ascii_digit)
195        && bytes[n - 2..].iter().all(u8::is_ascii_digit)
196    {
197        return Some(&id[..n - 11]);
198    }
199    None
200}
201
202/// Convert a `ConvertedEntry` from the shared convert module into a `ModelEntry`.
203fn convert_to_entry(c: convert::ConvertedEntry) -> Option<ModelEntry> {
204    let provider = parse_provider_enum(&c.provider);
205    let mode = parse_mode_enum(&c.mode)?;
206
207    let mut pricing = crate::types::Pricing::new(c.input_cost_per_token, c.output_cost_per_token);
208    if let Some(v) = c.cache_read_per_token {
209        pricing = pricing.with_cache_read(v);
210    }
211    if let Some(v) = c.cache_write_per_token {
212        pricing = pricing.with_cache_write(v);
213    }
214    if let Some(v) = c.reasoning_per_token {
215        pricing = pricing.with_reasoning(v);
216    }
217    if let Some(v) = c.image_per_token {
218        pricing = pricing.with_image(v);
219    }
220
221    let abilities: Vec<Ability> = c
222        .abilities
223        .iter()
224        .filter_map(|a| parse_ability(a))
225        .collect();
226
227    let mut entry = ModelEntry::new(c.litellm_id, c.canonical_id, provider, mode)
228        .with_pricing(pricing)
229        .with_abilities(abilities);
230
231    if let Some(max_in) = c.max_input_tokens {
232        entry = entry.with_max_input_tokens(max_in);
233    }
234    if let Some(max_out) = c.max_output_tokens {
235        entry = entry.with_max_output_tokens(max_out);
236    }
237    if let Some(date) = c.deprecation_date {
238        entry = entry.with_deprecation_date(date);
239    }
240
241    Some(entry)
242}
243
244fn parse_provider_enum(s: &str) -> Provider {
245    match s {
246        "openai" => Provider::OpenAI,
247        "anthropic" => Provider::Anthropic,
248        "gemini" => Provider::Google,
249        "deepseek" => Provider::DeepSeek,
250        "xai" => Provider::XAI,
251        "mistral" => Provider::Mistral,
252        "meta" => Provider::Meta,
253        "alibaba" => Provider::Alibaba,
254        "bedrock" => Provider::Bedrock,
255        "azure" => Provider::Azure,
256        "vertex_ai" => Provider::VertexAI,
257        "groq" => Provider::Groq,
258        "together_ai" => Provider::Together,
259        "fireworks_ai" => Provider::Fireworks,
260        "replicate" => Provider::Replicate,
261        "cohere" => Provider::Cohere,
262        "perplexity" => Provider::Perplexity,
263        "github_copilot" => Provider::Copilot,
264        other => Provider::Other(std::borrow::Cow::Owned(other.to_string())),
265    }
266}
267
268fn parse_mode_enum(s: &str) -> Option<ModelMode> {
269    match s {
270        "chat" => Some(ModelMode::Chat),
271        "embedding" => Some(ModelMode::Embedding),
272        "image_generation" => Some(ModelMode::ImageGeneration),
273        "audio_transcription" => Some(ModelMode::AudioTranscription),
274        "audio_speech" => Some(ModelMode::AudioSpeech),
275        "rerank" => Some(ModelMode::Rerank),
276        "moderation" => Some(ModelMode::Moderation),
277        "completion" => Some(ModelMode::Completion),
278        "video_generation" => Some(ModelMode::VideoGeneration),
279        "search" => Some(ModelMode::Search),
280        "ocr" => Some(ModelMode::Ocr),
281        _ => None,
282    }
283}
284
285fn parse_ability(s: &str) -> Option<Ability> {
286    match s {
287        "ToolUse" => Some(Ability::ToolUse),
288        "Vision" => Some(Ability::Vision),
289        "Audio" => Some(Ability::Audio),
290        "AudioOutput" => Some(Ability::AudioOutput),
291        "Video" => Some(Ability::Video),
292        "WebSearch" => Some(Ability::WebSearch),
293        "Pdf" => Some(Ability::Pdf),
294        "CodeExecution" => Some(Ability::CodeExecution),
295        "Reasoning" => Some(Ability::Reasoning),
296        "ImageGeneration" => Some(Ability::ImageGeneration),
297        "ComputerUse" => Some(Ability::ComputerUse),
298        "PromptCaching" => Some(Ability::PromptCaching),
299        "AssistantPrefill" => Some(Ability::AssistantPrefill),
300        _ => None,
301    }
302}