aither_models/
registry.rs1use 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}