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}
303
304#[cfg(test)]
305pub mod tests {
306    use super::*;
307
308    /// Registry built from a small hand-written `LiteLLM` dataset, so lookup
309    /// and tier behaviour is tested independently of the bundled snapshot,
310    /// which upstream refreshes can change at any time.
311    pub fn fixture() -> ModelRegistry {
312        ModelRegistry::from_litellm_json(include_bytes!("../testdata/litellm_fixture.json"))
313    }
314
315    fn litellm_id_of(registry: &ModelRegistry, query: &str) -> Option<String> {
316        registry
317            .lookup(query)
318            .map(|entry| entry.litellm_id().to_owned())
319    }
320
321    #[test]
322    fn fixture_skips_sample_spec_and_unknown_modes() {
323        let registry = fixture();
324        assert_eq!(registry.len(), 12);
325        assert!(registry.lookup("sample_spec").is_none());
326        assert!(registry.lookup("fixture-unknown-mode").is_none());
327    }
328
329    #[test]
330    fn exact_lookup_returns_converted_entry() {
331        let registry = fixture();
332        let entry = registry.lookup("fixture-gpt").expect("fixture-gpt exists");
333        assert_eq!(entry.id(), "fixture-gpt");
334        assert_eq!(*entry.provider(), Provider::OpenAI);
335        assert_eq!(entry.mode(), ModelMode::Chat);
336        assert_eq!(entry.max_input_tokens(), Some(128_000));
337        assert!(entry.has_ability(Ability::Vision));
338        assert!(entry.has_ability(Ability::ToolUse));
339        assert!(entry.pricing().input_per_token() > 0.0);
340        assert!(entry.pricing().output_per_token() > 0.0);
341    }
342
343    #[test]
344    fn lookup_is_case_insensitive() {
345        let registry = fixture();
346        assert_eq!(
347            litellm_id_of(&registry, "FIXTURE-GPT").as_deref(),
348            Some("fixture-gpt")
349        );
350        assert_eq!(
351            litellm_id_of(&registry, "GITHUB_COPILOT/FIXTURE-HOSTED").as_deref(),
352            Some("github_copilot/fixture-hosted")
353        );
354    }
355
356    #[test]
357    fn dated_snapshot_with_own_record_matches_exactly() {
358        let registry = fixture();
359        assert_eq!(
360            litellm_id_of(&registry, "fixture-gpt-2024-05-13").as_deref(),
361            Some("fixture-gpt-2024-05-13")
362        );
363    }
364
365    #[test]
366    fn version_suffixes_normalize_to_base_model() {
367        let registry = fixture();
368        for alias in [
369            "fixture-gpt-21000101",
370            "fixture-gpt-2099-01-01",
371            "fixture-gpt@20240513",
372            "fixture-gpt-v2",
373            "fixture-gpt-v2:1",
374        ] {
375            assert_eq!(
376                litellm_id_of(&registry, alias).as_deref(),
377                Some("fixture-gpt"),
378                "{alias} should normalize to fixture-gpt"
379            );
380        }
381        // Normalization stays within the queried family.
382        assert_eq!(
383            litellm_id_of(&registry, "fixture-gpt-mini-20250101").as_deref(),
384            Some("fixture-gpt-mini")
385        );
386    }
387
388    #[test]
389    fn normalization_never_changes_model_family() {
390        let registry = fixture();
391        assert!(registry.lookup("fixture-gpt-turbo").is_none());
392        assert!(registry.lookup("fixture-gpt-mini-turbo").is_none());
393    }
394
395    #[test]
396    fn unknown_id_returns_none() {
397        let registry = fixture();
398        assert!(registry.lookup("no-such-model-xyz").is_none());
399        assert!(registry.lookup("no-such-model-xyz-20250101").is_none());
400    }
401
402    #[test]
403    fn bare_id_does_not_bind_provider_scoped_record() {
404        let registry = fixture();
405        // Only a hosted (`github_copilot/`, `azure/`) record exists, so the
406        // bare ID — dated or not — is a miss rather than a fallback.
407        assert!(registry.lookup("fixture-hosted").is_none());
408        assert!(registry.lookup("fixture-hosted-20250514").is_none());
409        assert!(registry.lookup("fixture-azure-only").is_none());
410        assert!(registry.lookup("fixture-bedrock-v1:0").is_none());
411    }
412
413    #[test]
414    fn provider_scoped_query_normalizes_within_its_namespace() {
415        let registry = fixture();
416        assert_eq!(
417            litellm_id_of(&registry, "github_copilot/fixture-hosted-20250514").as_deref(),
418            Some("github_copilot/fixture-hosted")
419        );
420        assert_eq!(
421            litellm_id_of(&registry, "azure/fixture-azure-only").as_deref(),
422            Some("azure/fixture-azure-only")
423        );
424        assert_eq!(
425            litellm_id_of(&registry, "bedrock/fixture-bedrock-v1:0").as_deref(),
426            Some("bedrock/fixture-bedrock-v1:0")
427        );
428        // Another provider's namespace does not answer for this one.
429        assert!(registry.lookup("azure/fixture-hosted").is_none());
430    }
431
432    #[test]
433    fn first_party_prefix_claims_bare_id() {
434        let registry = fixture();
435        assert_eq!(
436            litellm_id_of(&registry, "fixture-gemini").as_deref(),
437            Some("gemini/fixture-gemini")
438        );
439        assert_eq!(
440            litellm_id_of(&registry, "fixture-gemini-2099-01-01").as_deref(),
441            Some("gemini/fixture-gemini")
442        );
443    }
444
445    #[test]
446    fn unprefixed_record_wins_bare_id_over_first_party_prefix() {
447        let registry = fixture();
448        // `anthropic/fixture-claude-mid` precedes the unprefixed record in
449        // iteration order; the unprefixed key still owns the bare ID.
450        assert_eq!(
451            litellm_id_of(&registry, "fixture-claude-mid").as_deref(),
452            Some("fixture-claude-mid")
453        );
454        assert_eq!(
455            litellm_id_of(&registry, "anthropic/fixture-claude-mid").as_deref(),
456            Some("anthropic/fixture-claude-mid")
457        );
458    }
459
460    #[test]
461    fn query_helpers_filter_by_mode_ability_and_provider() {
462        let registry = fixture();
463        let image: Vec<_> = registry
464            .models_by_mode(ModelMode::ImageGeneration)
465            .map(ModelEntry::litellm_id)
466            .collect();
467        assert_eq!(image, ["fixture-image"]);
468        let reasoning: Vec<_> = registry
469            .models_with_ability(Ability::Reasoning)
470            .map(ModelEntry::litellm_id)
471            .collect();
472        assert_eq!(reasoning, ["fixture-flagship"]);
473        let copilot: Vec<_> = registry
474            .models_for_provider(&Provider::Copilot)
475            .map(ModelEntry::litellm_id)
476            .collect();
477        assert_eq!(copilot, ["github_copilot/fixture-hosted"]);
478    }
479}