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]
99 pub fn lookup(&self, model_id: &str) -> Option<&ModelEntry> {
100 let mut id = model_id.to_lowercase();
101 loop {
102 if let Some(&idx) = self.index.get(id.as_str()) {
103 return Some(&self.entries[idx]);
104 }
105 id = next_lookup_form(&id)?;
106 }
107 }
108
109 pub fn models_for_provider(&self, provider: &Provider) -> impl Iterator<Item = &ModelEntry> {
111 let provider = provider.clone();
112 self.entries
113 .iter()
114 .filter(move |e| *e.provider() == provider)
115 }
116
117 pub fn models_by_mode(&self, mode: ModelMode) -> impl Iterator<Item = &ModelEntry> {
119 self.entries.iter().filter(move |e| e.mode() == mode)
120 }
121
122 pub fn models_with_ability(&self, ability: Ability) -> impl Iterator<Item = &ModelEntry> {
124 self.entries.iter().filter(move |e| e.has_ability(ability))
125 }
126
127 pub fn all(&self) -> impl Iterator<Item = &ModelEntry> {
129 self.entries.iter()
130 }
131
132 #[must_use]
134 pub const fn len(&self) -> usize {
135 self.entries.len()
136 }
137
138 #[must_use]
140 pub const fn is_empty(&self) -> bool {
141 self.entries.is_empty()
142 }
143}
144
145const FIRST_PARTY_PREFIXES: &[&str] = &[
150 "anthropic/",
151 "cohere/",
152 "cohere_chat/",
153 "dashscope/",
154 "deepseek/",
155 "gemini/",
156 "meta-llama/",
157 "mistral/",
158 "openai/",
159 "xai/",
160];
161
162fn is_first_party(entry: &ModelEntry) -> bool {
166 entry.litellm_id() == entry.id()
167 || FIRST_PARTY_PREFIXES
168 .iter()
169 .any(|prefix| entry.litellm_id().starts_with(prefix))
170}
171
172fn next_lookup_form(id: &str) -> Option<String> {
178 if let Some(stripped) = strip_version_suffix(id) {
179 return Some(stripped.to_owned());
180 }
181 let minor = find_v_minor(id)?;
182 if minor.sep_byte == b'-' {
183 let mut dotted = id.to_owned();
184 dotted.replace_range(minor.sep..=minor.sep, ".");
185 return Some(dotted);
186 }
187 let mut collapsed = String::with_capacity(id.len() - (minor.minor_end - minor.sep));
188 collapsed.push_str(&id[..minor.sep]);
189 collapsed.push_str(&id[minor.minor_end..]);
190 Some(collapsed)
191}
192
193fn strip_version_suffix(id: &str) -> Option<&str> {
198 let bytes = id.as_bytes();
199 let n = bytes.len();
200 if let Some((base, suffix)) = id.rsplit_once('-')
201 && let Some(rest) = suffix.strip_prefix('v')
202 && rest
203 .split(':')
204 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
205 {
206 return Some(base);
207 }
208 if n > 9 && matches!(bytes[n - 9], b'-' | b'@') && bytes[n - 8..].iter().all(u8::is_ascii_digit)
209 {
210 return Some(&id[..n - 9]);
211 }
212 if n > 11
213 && bytes[n - 11] == b'-'
214 && bytes[n - 6] == b'-'
215 && bytes[n - 3] == b'-'
216 && bytes[n - 10..n - 6].iter().all(u8::is_ascii_digit)
217 && bytes[n - 5..n - 3].iter().all(u8::is_ascii_digit)
218 && bytes[n - 2..].iter().all(u8::is_ascii_digit)
219 {
220 return Some(&id[..n - 11]);
221 }
222 if n > 5 && bytes[n - 5] == b'-' && is_compact_month_day(&bytes[n - 4..]) {
223 return Some(&id[..n - 5]);
224 }
225 None
226}
227
228fn is_compact_month_day(digits: &[u8]) -> bool {
230 if digits.len() != 4 || !digits.iter().all(u8::is_ascii_digit) {
231 return false;
232 }
233 let month = (digits[0] - b'0') * 10 + (digits[1] - b'0');
234 let day = (digits[2] - b'0') * 10 + (digits[3] - b'0');
235 (1..=12).contains(&month) && (1..=31).contains(&day)
236}
237
238struct VMinor {
240 sep: usize,
242 minor_end: usize,
244 sep_byte: u8,
246}
247
248fn find_v_minor(id: &str) -> Option<VMinor> {
250 let bytes = id.as_bytes();
251 let mut i = 0;
252 while i < bytes.len() {
253 let at_token_start = i == 0 || bytes[i - 1] == b'-' || bytes[i - 1] == b'/';
254 if bytes[i] != b'v' || !at_token_start {
255 i += 1;
256 continue;
257 }
258 let mut j = i + 1;
259 if j >= bytes.len() || !bytes[j].is_ascii_digit() {
260 i += 1;
261 continue;
262 }
263 while j < bytes.len() && bytes[j].is_ascii_digit() {
264 j += 1;
265 }
266 let Some(&sep_byte) = bytes.get(j) else {
267 i += 1;
268 continue;
269 };
270 if sep_byte != b'-' && sep_byte != b'.' {
271 i += 1;
272 continue;
273 }
274 let mut k = j + 1;
275 let minor_start = k;
276 while k < bytes.len() && bytes[k].is_ascii_digit() {
277 k += 1;
278 }
279 let minor_len = k - minor_start;
280 if !(1..=2).contains(&minor_len) {
281 i += 1;
282 continue;
283 }
284 if k != bytes.len() && bytes[k] != b'-' {
285 i += 1;
286 continue;
287 }
288 return Some(VMinor {
289 sep: j,
290 minor_end: k,
291 sep_byte,
292 });
293 }
294 None
295}
296
297fn convert_to_entry(c: convert::ConvertedEntry) -> Option<ModelEntry> {
299 let provider = parse_provider_enum(&c.provider);
300 let mode = parse_mode_enum(&c.mode)?;
301
302 let mut pricing = crate::types::Pricing::new(c.input_cost_per_token, c.output_cost_per_token);
303 if let Some(v) = c.cache_read_per_token {
304 pricing = pricing.with_cache_read(v);
305 }
306 if let Some(v) = c.cache_write_per_token {
307 pricing = pricing.with_cache_write(v);
308 }
309 if let Some(v) = c.reasoning_per_token {
310 pricing = pricing.with_reasoning(v);
311 }
312 if let Some(v) = c.image_per_token {
313 pricing = pricing.with_image(v);
314 }
315
316 let abilities: Vec<Ability> = c
317 .abilities
318 .iter()
319 .filter_map(|a| parse_ability(a))
320 .collect();
321
322 let mut entry = ModelEntry::new(c.litellm_id, c.canonical_id, provider, mode)
323 .with_pricing(pricing)
324 .with_abilities(abilities);
325
326 if let Some(max_in) = c.max_input_tokens {
327 entry = entry.with_max_input_tokens(max_in);
328 }
329 if let Some(max_out) = c.max_output_tokens {
330 entry = entry.with_max_output_tokens(max_out);
331 }
332 if let Some(date) = c.deprecation_date {
333 entry = entry.with_deprecation_date(date);
334 }
335
336 Some(entry)
337}
338
339fn parse_provider_enum(s: &str) -> Provider {
340 match s {
341 "openai" => Provider::OpenAI,
342 "anthropic" => Provider::Anthropic,
343 "gemini" => Provider::Google,
344 "deepseek" => Provider::DeepSeek,
345 "xai" => Provider::XAI,
346 "mistral" => Provider::Mistral,
347 "meta" => Provider::Meta,
348 "alibaba" => Provider::Alibaba,
349 "bedrock" => Provider::Bedrock,
350 "azure" => Provider::Azure,
351 "vertex_ai" => Provider::VertexAI,
352 "groq" => Provider::Groq,
353 "together_ai" => Provider::Together,
354 "fireworks_ai" => Provider::Fireworks,
355 "replicate" => Provider::Replicate,
356 "cohere" => Provider::Cohere,
357 "perplexity" => Provider::Perplexity,
358 "github_copilot" => Provider::Copilot,
359 other => Provider::Other(std::borrow::Cow::Owned(other.to_string())),
360 }
361}
362
363fn parse_mode_enum(s: &str) -> Option<ModelMode> {
364 match s {
365 "chat" => Some(ModelMode::Chat),
366 "embedding" => Some(ModelMode::Embedding),
367 "image_generation" => Some(ModelMode::ImageGeneration),
368 "audio_transcription" => Some(ModelMode::AudioTranscription),
369 "audio_speech" => Some(ModelMode::AudioSpeech),
370 "rerank" => Some(ModelMode::Rerank),
371 "moderation" => Some(ModelMode::Moderation),
372 "completion" => Some(ModelMode::Completion),
373 "video_generation" => Some(ModelMode::VideoGeneration),
374 "search" => Some(ModelMode::Search),
375 "ocr" => Some(ModelMode::Ocr),
376 _ => None,
377 }
378}
379
380fn parse_ability(s: &str) -> Option<Ability> {
381 match s {
382 "ToolUse" => Some(Ability::ToolUse),
383 "Vision" => Some(Ability::Vision),
384 "Audio" => Some(Ability::Audio),
385 "AudioOutput" => Some(Ability::AudioOutput),
386 "Video" => Some(Ability::Video),
387 "WebSearch" => Some(Ability::WebSearch),
388 "Pdf" => Some(Ability::Pdf),
389 "CodeExecution" => Some(Ability::CodeExecution),
390 "Reasoning" => Some(Ability::Reasoning),
391 "ImageGeneration" => Some(Ability::ImageGeneration),
392 "ComputerUse" => Some(Ability::ComputerUse),
393 "PromptCaching" => Some(Ability::PromptCaching),
394 "AssistantPrefill" => Some(Ability::AssistantPrefill),
395 "PerMessageEffort" => Some(Ability::PerMessageEffort),
396 _ => None,
397 }
398}
399
400#[cfg(test)]
401pub mod tests {
402 use super::*;
403
404 pub fn fixture() -> ModelRegistry {
408 ModelRegistry::from_litellm_json(include_bytes!("../testdata/litellm_fixture.json"))
409 }
410
411 fn litellm_id_of(registry: &ModelRegistry, query: &str) -> Option<String> {
412 registry
413 .lookup(query)
414 .map(|entry| entry.litellm_id().to_owned())
415 }
416
417 #[test]
420 fn per_message_effort_follows_the_documented_models_and_platforms() {
421 use crate::convert::takes_per_message_effort;
422 for (id, expected) in [
423 ("claude-opus-5-5", true),
424 ("claude-sonnet-5-5", true),
425 ("claude-fable-5-1", true),
426 ("claude-sonnet-5", false),
427 ("claude-fable-5", false),
428 ("gpt-6-sol", true),
429 ("gpt-6.1-sol", true),
430 ("gpt-5", false),
431 ] {
432 let entry = crate::lookup(id).unwrap_or_else(|| panic!("{id} is in the snapshot"));
433 assert_eq!(
434 entry.has_ability(Ability::PerMessageEffort),
435 expected,
436 "{id}"
437 );
438 }
439 assert!(takes_per_message_effort(
440 "vertex_ai",
441 "claude-opus-5-5@default"
442 ));
443 assert!(!takes_per_message_effort(
444 "bedrock",
445 "anthropic.claude-opus-5-5"
446 ));
447 assert!(!takes_per_message_effort("azure", "gpt-6-sol"));
448 }
449
450 #[test]
451 fn fixture_skips_sample_spec_and_unknown_modes() {
452 let registry = fixture();
453 assert_eq!(registry.len(), 13);
454 assert!(registry.lookup("sample_spec").is_none());
455 assert!(registry.lookup("fixture-unknown-mode").is_none());
456 }
457
458 #[test]
459 fn exact_lookup_returns_converted_entry() {
460 let registry = fixture();
461 let entry = registry.lookup("fixture-gpt").expect("fixture-gpt exists");
462 assert_eq!(entry.id(), "fixture-gpt");
463 assert_eq!(*entry.provider(), Provider::OpenAI);
464 assert_eq!(entry.mode(), ModelMode::Chat);
465 assert_eq!(entry.max_input_tokens(), Some(128_000));
466 assert!(entry.has_ability(Ability::Vision));
467 assert!(entry.has_ability(Ability::ToolUse));
468 assert!(entry.pricing().input_per_token() > 0.0);
469 assert!(entry.pricing().output_per_token() > 0.0);
470 }
471
472 #[test]
473 fn lookup_is_case_insensitive() {
474 let registry = fixture();
475 assert_eq!(
476 litellm_id_of(®istry, "FIXTURE-GPT").as_deref(),
477 Some("fixture-gpt")
478 );
479 assert_eq!(
480 litellm_id_of(®istry, "GITHUB_COPILOT/FIXTURE-HOSTED").as_deref(),
481 Some("github_copilot/fixture-hosted")
482 );
483 }
484
485 #[test]
486 fn dated_snapshot_with_own_record_matches_exactly() {
487 let registry = fixture();
488 assert_eq!(
489 litellm_id_of(®istry, "fixture-gpt-2024-05-13").as_deref(),
490 Some("fixture-gpt-2024-05-13")
491 );
492 }
493
494 #[test]
495 fn version_suffixes_normalize_to_base_model() {
496 let registry = fixture();
497 for alias in [
498 "fixture-gpt-21000101",
499 "fixture-gpt-2099-01-01",
500 "fixture-gpt@20240513",
501 "fixture-gpt-v2",
502 "fixture-gpt-v2:1",
503 "fixture-gpt-1017",
504 ] {
505 assert_eq!(
506 litellm_id_of(®istry, alias).as_deref(),
507 Some("fixture-gpt"),
508 "{alias} should normalize to fixture-gpt"
509 );
510 }
511 assert_eq!(
513 litellm_id_of(®istry, "fixture-gpt-mini-20250101").as_deref(),
514 Some("fixture-gpt-mini")
515 );
516 }
517
518 #[test]
519 fn hyphenated_v_minor_and_compact_date_normalize_to_base() {
520 let registry = fixture();
521 for alias in [
522 "fixture-v4-flash-1017",
523 "fixture-v4-1-flash",
524 "fixture-v4.1-flash",
525 "fixture-v4-1-flash-1017",
526 ] {
527 assert_eq!(
528 litellm_id_of(®istry, alias).as_deref(),
529 Some("fixture-v4-flash"),
530 "{alias} should normalize to fixture-v4-flash"
531 );
532 assert_eq!(
533 registry
534 .lookup(alias)
535 .and_then(ModelEntry::max_input_tokens),
536 Some(1_000_000),
537 "{alias} should inherit the 1M window"
538 );
539 }
540 }
541
542 #[test]
543 fn hyphenated_v_minor_prefers_dotted_record_when_present() {
544 let registry = ModelRegistry::from_litellm_json(
545 br#"{
546 "fixture-v4-flash": {
547 "litellm_provider": "deepseek",
548 "mode": "chat",
549 "max_input_tokens": 1000000,
550 "input_cost_per_token": 3e-07,
551 "output_cost_per_token": 1.2e-06
552 },
553 "fixture-v4.1-flash": {
554 "litellm_provider": "deepseek",
555 "mode": "chat",
556 "max_input_tokens": 1048576,
557 "input_cost_per_token": 3e-07,
558 "output_cost_per_token": 1.2e-06
559 }
560 }"#,
561 );
562 assert_eq!(
563 litellm_id_of(®istry, "fixture-v4-1-flash").as_deref(),
564 Some("fixture-v4.1-flash")
565 );
566 assert_eq!(
567 registry
568 .lookup("fixture-v4-1-flash")
569 .and_then(ModelEntry::max_input_tokens),
570 Some(1_048_576)
571 );
572 }
573
574 #[test]
575 fn compact_month_day_must_be_a_valid_calendar_date() {
576 let registry = fixture();
577 assert!(registry.lookup("fixture-gpt-1301").is_none());
578 assert!(registry.lookup("fixture-gpt-0001").is_none());
579 assert!(registry.lookup("fixture-gpt-4096").is_none());
580 }
581
582 #[test]
583 fn numeric_minor_without_v_prefix_does_not_collapse() {
584 let registry = fixture();
585 assert!(registry.lookup("fixture-gpt-4-1").is_none());
586 assert!(registry.lookup("fixture-gpt-4.1").is_none());
587 }
588
589 #[test]
590 fn normalization_never_changes_model_family() {
591 let registry = fixture();
592 assert!(registry.lookup("fixture-gpt-turbo").is_none());
593 assert!(registry.lookup("fixture-gpt-mini-turbo").is_none());
594 }
595
596 #[test]
597 fn unknown_id_returns_none() {
598 let registry = fixture();
599 assert!(registry.lookup("no-such-model-xyz").is_none());
600 assert!(registry.lookup("no-such-model-xyz-20250101").is_none());
601 }
602
603 #[test]
604 fn bare_id_does_not_bind_provider_scoped_record() {
605 let registry = fixture();
606 assert!(registry.lookup("fixture-hosted").is_none());
609 assert!(registry.lookup("fixture-hosted-20250514").is_none());
610 assert!(registry.lookup("fixture-azure-only").is_none());
611 assert!(registry.lookup("fixture-bedrock-v1:0").is_none());
612 }
613
614 #[test]
615 fn provider_scoped_query_normalizes_within_its_namespace() {
616 let registry = fixture();
617 assert_eq!(
618 litellm_id_of(®istry, "github_copilot/fixture-hosted-20250514").as_deref(),
619 Some("github_copilot/fixture-hosted")
620 );
621 assert_eq!(
622 litellm_id_of(®istry, "azure/fixture-azure-only").as_deref(),
623 Some("azure/fixture-azure-only")
624 );
625 assert_eq!(
626 litellm_id_of(®istry, "bedrock/fixture-bedrock-v1:0").as_deref(),
627 Some("bedrock/fixture-bedrock-v1:0")
628 );
629 assert!(registry.lookup("azure/fixture-hosted").is_none());
631 }
632
633 #[test]
634 fn first_party_prefix_claims_bare_id() {
635 let registry = fixture();
636 assert_eq!(
637 litellm_id_of(®istry, "fixture-gemini").as_deref(),
638 Some("gemini/fixture-gemini")
639 );
640 assert_eq!(
641 litellm_id_of(®istry, "fixture-gemini-2099-01-01").as_deref(),
642 Some("gemini/fixture-gemini")
643 );
644 }
645
646 #[test]
647 fn unprefixed_record_wins_bare_id_over_first_party_prefix() {
648 let registry = fixture();
649 assert_eq!(
652 litellm_id_of(®istry, "fixture-claude-mid").as_deref(),
653 Some("fixture-claude-mid")
654 );
655 assert_eq!(
656 litellm_id_of(®istry, "anthropic/fixture-claude-mid").as_deref(),
657 Some("anthropic/fixture-claude-mid")
658 );
659 }
660
661 #[test]
662 fn query_helpers_filter_by_mode_ability_and_provider() {
663 let registry = fixture();
664 let image: Vec<_> = registry
665 .models_by_mode(ModelMode::ImageGeneration)
666 .map(ModelEntry::litellm_id)
667 .collect();
668 assert_eq!(image, ["fixture-image"]);
669 let reasoning: Vec<_> = registry
670 .models_with_ability(Ability::Reasoning)
671 .map(ModelEntry::litellm_id)
672 .collect();
673 assert_eq!(reasoning, ["fixture-flagship"]);
674 let copilot: Vec<_> = registry
675 .models_for_provider(&Provider::Copilot)
676 .map(ModelEntry::litellm_id)
677 .collect();
678 assert_eq!(copilot, ["github_copilot/fixture-hosted"]);
679 }
680}