1use 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
12include!(concat!(env!("OUT_DIR"), "/generated.rs"));
17
18#[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 #[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 #[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 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 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 #[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 #[must_use]
84 pub fn tier_of(&self, entry: &ModelEntry) -> ModelTier {
85 classify_entry(entry, self.chat_price_breakpoints)
86 }
87
88 #[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 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 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 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 pub fn all(&self) -> impl Iterator<Item = &ModelEntry> {
128 self.entries.iter()
129 }
130
131 #[must_use]
133 pub const fn len(&self) -> usize {
134 self.entries.len()
135 }
136
137 #[must_use]
139 pub const fn is_empty(&self) -> bool {
140 self.entries.is_empty()
141 }
142}
143
144const 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
161fn 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
171fn 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
202fn 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 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(®istry, "FIXTURE-GPT").as_deref(),
348 Some("fixture-gpt")
349 );
350 assert_eq!(
351 litellm_id_of(®istry, "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(®istry, "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(®istry, alias).as_deref(),
377 Some("fixture-gpt"),
378 "{alias} should normalize to fixture-gpt"
379 );
380 }
381 assert_eq!(
383 litellm_id_of(®istry, "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 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(®istry, "github_copilot/fixture-hosted-20250514").as_deref(),
418 Some("github_copilot/fixture-hosted")
419 );
420 assert_eq!(
421 litellm_id_of(®istry, "azure/fixture-azure-only").as_deref(),
422 Some("azure/fixture-azure-only")
423 );
424 assert_eq!(
425 litellm_id_of(®istry, "bedrock/fixture-bedrock-v1:0").as_deref(),
426 Some("bedrock/fixture-bedrock-v1:0")
427 );
428 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(®istry, "fixture-gemini").as_deref(),
437 Some("gemini/fixture-gemini")
438 );
439 assert_eq!(
440 litellm_id_of(®istry, "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 assert_eq!(
451 litellm_id_of(®istry, "fixture-claude-mid").as_deref(),
452 Some("fixture-claude-mid")
453 );
454 assert_eq!(
455 litellm_id_of(®istry, "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}