1use std::collections::HashMap;
24use std::sync::OnceLock;
25
26use crate::catalog::BuiltinModelEntry;
27use crate::{Api, InputModality};
28
29fn parse_api(s: &str) -> Api {
42 Api::from_kebab_str(s).unwrap_or(Api::OpenAiCompletions)
44}
45
46fn parse_input_modality(s: &str) -> InputModality {
47 match s {
48 "text" | "Text" => InputModality::Text,
49 "image" | "Image" => InputModality::Image,
50 _ => InputModality::Text,
51 }
52}
53
54impl From<&BuiltinModelEntry> for ModelEntry {
55 fn from(e: &BuiltinModelEntry) -> Self {
56 let id: &'static str = Box::leak(e.id.clone().into_boxed_str());
59 let name: &'static str = Box::leak(e.name.clone().into_boxed_str());
60 let provider: &'static str = Box::leak(e.provider.clone().into_boxed_str());
61 let input: &'static [InputModality] = Box::leak(
62 e.input
63 .iter()
64 .map(|s| parse_input_modality(s))
65 .collect::<Vec<_>>()
66 .into_boxed_slice(),
67 );
68 ModelEntry {
74 id,
75 name,
76 api: parse_api(&e.api),
77 provider,
78 reasoning: e.reasoning,
79 input,
80 cost_input: e.cost_input,
81 cost_output: e.cost_output,
82 cost_cache_read: e.cost_cache_read,
83 cost_cache_write: e.cost_cache_write,
84 context_window: e.context_window,
85 max_tokens: e.max_tokens,
86 }
87 }
88}
89
90pub const UNVERIFIED_PRICE: f64 = -1.0;
96
97#[derive(Debug, Clone, Copy, PartialEq)]
112pub struct ModelEntry {
113 pub id: &'static str,
115 pub name: &'static str,
117 pub api: Api,
119 pub provider: &'static str,
121 pub reasoning: bool,
123 pub input: &'static [InputModality],
125 pub cost_input: f64,
127 pub cost_output: f64,
129 pub cost_cache_read: f64,
131 pub cost_cache_write: f64,
133 pub context_window: u32,
135 pub max_tokens: u32,
137}
138
139impl ModelEntry {
140 pub fn supports_vision(&self) -> bool {
142 self.input.contains(&InputModality::Image)
143 }
144
145 pub fn supports_reasoning(&self) -> bool {
147 self.reasoning
148 }
149
150 pub fn calculate_cost(
157 &self,
158 input_tokens: u64,
159 output_tokens: u64,
160 cache_read: u64,
161 cache_write: u64,
162 ) -> f64 {
163 let in_cost = (input_tokens as f64 / 1_000_000.0) * self.cost_input.max(0.0);
164 let out_cost = (output_tokens as f64 / 1_000_000.0) * self.cost_output.max(0.0);
165 let cr_cost = (cache_read as f64 / 1_000_000.0) * self.cost_cache_read.max(0.0);
166 let cw_cost = (cache_write as f64 / 1_000_000.0) * self.cost_cache_write.max(0.0);
167 in_cost + out_cost + cr_cost + cw_cost
168 }
169
170 pub fn pricing_verified(&self) -> bool {
184 self.cost_input >= 0.0 && self.cost_output >= 0.0
185 }
186
187 pub fn pricing_unverified(&self) -> bool {
189 self.cost_input < 0.0 || self.cost_output < 0.0
190 }
191}
192
193static ALL_PROVIDER_MODELS: OnceLock<Vec<(&'static str, &'static [ModelEntry])>> = OnceLock::new();
202
203fn all_provider_models() -> &'static [(&'static str, &'static [ModelEntry])] {
204 ALL_PROVIDER_MODELS
205 .get_or_init(|| {
206 #[allow(clippy::expect_used)]
212 try_materialize_from_snapshot().expect(
213 "Failed to materialize from embedded snapshot. \
214 The catalog snapshot is required for oxicode to function.",
215 )
216 })
217 .as_slice()
218}
219
220fn try_materialize_from_snapshot() -> Option<Vec<(&'static str, &'static [ModelEntry])>> {
222 let catalog = crate::catalog::materialize::load_snapshot_catalog()?;
223 let product_meta = crate::catalog::ProductMeta::builtin();
224 let overrides = crate::catalog::load_overrides().unwrap_or_default();
225 let (_providers, models_by_pid) =
226 crate::catalog::materialize(&catalog, &product_meta, &overrides);
227 let mut out: Vec<(&'static str, &'static [ModelEntry])> =
228 Vec::with_capacity(models_by_pid.len());
229 for (pid, entries) in models_by_pid {
230 let pid_static: &'static str = Box::leak(pid.into_boxed_str());
231 let model_entries: Vec<ModelEntry> = entries.iter().map(ModelEntry::from).collect();
232 let slice: &'static [ModelEntry] = Box::leak(model_entries.into_boxed_slice());
233 out.push((pid_static, slice));
234 }
235 out.sort_by(|a, b| a.0.cmp(b.0));
236 Some(out)
237}
238
239pub fn try_materialize_all() -> Option<Vec<(&'static str, &'static [ModelEntry])>> {
243 let catalog = crate::catalog::models_dev::get()?;
244 let product_meta = crate::catalog::ProductMeta::builtin();
245 let overrides = crate::catalog::load_overrides().unwrap_or_default();
246 let (_providers, models_by_pid) =
247 crate::catalog::materialize(catalog, &product_meta, &overrides);
248 let mut out: Vec<(&'static str, &'static [ModelEntry])> =
249 Vec::with_capacity(models_by_pid.len());
250 for (pid, entries) in models_by_pid {
251 let pid_static: &'static str = Box::leak(pid.into_boxed_str());
252 let model_entries: Vec<ModelEntry> = entries.iter().map(ModelEntry::from).collect();
253 let slice: &'static [ModelEntry] = Box::leak(model_entries.into_boxed_slice());
254 out.push((pid_static, slice));
255 }
256 out.sort_by(|a, b| a.0.cmp(b.0));
257 Some(out)
258}
259
260static MODEL_INDEX: OnceLock<HashMap<&'static str, &'static ModelEntry>> = OnceLock::new();
264
265fn model_index() -> &'static HashMap<&'static str, &'static ModelEntry> {
266 MODEL_INDEX.get_or_init(|| {
267 let mut map = HashMap::with_capacity(model_count());
268 for (provider, models) in all_provider_models().iter() {
269 for model in models.iter() {
270 let key = format!("{}/{}", provider, model.id);
271 let key_static: &'static str = Box::leak(key.into_boxed_str());
272 map.insert(key_static, model);
273 }
274 }
275 map
276 })
277}
278
279static PROVIDER_INDEX: OnceLock<HashMap<&'static str, &'static [ModelEntry]>> = OnceLock::new();
281
282fn provider_index() -> &'static HashMap<&'static str, &'static [ModelEntry]> {
283 PROVIDER_INDEX.get_or_init(|| {
284 let mut map = HashMap::with_capacity(all_provider_models().len());
285 for (provider, models) in all_provider_models().iter() {
286 map.insert(*provider, *models);
287 }
288 map
289 })
290}
291
292pub fn get_model_entry(provider: &str, id: &str) -> Option<&'static ModelEntry> {
312 let key = format!("{}/{}", provider, id);
313 model_index().get(key.as_str()).copied()
314}
315
316pub fn get_provider_models(provider: &str) -> &'static [ModelEntry] {
326 provider_index().get(provider).copied().unwrap_or(&[])
327}
328
329pub fn get_all_models() -> impl Iterator<Item = &'static ModelEntry> {
333 all_provider_models()
334 .iter()
335 .flat_map(|(_, models)| models.iter())
336}
337
338pub fn model_count() -> usize {
340 all_provider_models().iter().map(|(_, m)| m.len()).sum()
341}
342
343pub fn builtin_model_count_sentinel() -> usize {
349 get_all_models().filter(|m| m.pricing_unverified()).count()
350}
351
352pub fn get_providers() -> Vec<&'static str> {
354 all_provider_models()
355 .iter()
356 .map(|(name, _)| *name)
357 .collect()
358}
359
360pub fn search_models(pattern: &str) -> Vec<&'static ModelEntry> {
362 let lower = pattern.to_lowercase();
363 get_all_models()
364 .filter(|m| m.id.to_lowercase().contains(&lower) || m.name.to_lowercase().contains(&lower))
365 .collect()
366}
367
368pub fn get_reasoning_models() -> Vec<&'static ModelEntry> {
370 get_all_models().filter(|m| m.reasoning).collect()
371}
372
373pub fn get_vision_models() -> Vec<&'static ModelEntry> {
375 get_all_models().filter(|m| m.supports_vision()).collect()
376}
377
378pub fn get_cheapest_models(limit: usize) -> Vec<&'static ModelEntry> {
380 let mut all: Vec<_> = get_all_models().collect();
381 all.sort_by(|a, b| {
382 a.cost_input
383 .partial_cmp(&b.cost_input)
384 .unwrap_or(std::cmp::Ordering::Equal)
385 });
386 all.truncate(limit);
387 all
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn test_total_model_count() {
396 let count = model_count();
397 assert!(count >= 934, "Expected at least 934 models, got {}", count);
398 }
399
400 #[test]
401 fn test_get_anthropic_model() {
402 let m = get_model_entry("anthropic", "claude-3-5-sonnet-20240620");
403 assert!(m.is_some(), "Claude Sonnet 3.5 should exist");
404 let m = m.unwrap();
405 assert_eq!(m.provider, "anthropic");
406 assert!(m.context_window >= 200_000);
407 }
408
409 #[test]
410 fn test_get_openai_model() {
411 let m = get_model_entry("openai", "gpt-4o");
412 assert!(m.is_some(), "GPT-4o should exist");
413 let m = m.unwrap();
414 assert_eq!(m.provider, "openai");
415 }
416
417 #[test]
418 fn test_provider_models() {
419 let anthropic = get_provider_models("anthropic");
420 assert!(!anthropic.is_empty(), "Anthropic should have models");
421 assert!(anthropic.iter().all(|m| m.provider == "anthropic"));
422
423 let unknown = get_provider_models("nonexistent-provider");
424 assert!(unknown.is_empty());
425 }
426
427 #[test]
428 fn test_search_models() {
429 let results = search_models("claude");
430 assert!(!results.is_empty(), "Should find Claude models");
431 assert!(
432 results
433 .iter()
434 .all(|m| m.name.to_lowercase().contains("claude")
435 || m.id.to_lowercase().contains("claude"))
436 );
437 }
438
439 #[test]
440 fn test_all_providers() {
441 let providers = get_providers();
442 assert!(providers.contains(&"openai"), "Should have openai");
443 assert!(providers.contains(&"anthropic"), "Should have anthropic");
444 }
445
446 #[test]
447 fn test_reasoning_models() {
448 let reasoning = get_reasoning_models();
449 assert!(!reasoning.is_empty(), "Should have reasoning models");
450 assert!(reasoning.iter().all(|m| m.reasoning));
451 }
452
453 #[test]
454 fn test_vision_models() {
455 let vision = get_vision_models();
456 assert!(!vision.is_empty(), "Should have vision models");
457 assert!(vision.iter().all(|m| m.supports_vision()));
458 }
459
460 #[test]
461
462 fn test_cheapest_models() {
463 let cheapest = get_cheapest_models(5);
464 assert_eq!(cheapest.len(), 5.min(model_count()));
465 for i in 1..cheapest.len() {
466 assert!(cheapest[i].cost_input >= cheapest[i - 1].cost_input);
467 }
468 }
469
470 #[test]
471 fn try_materialize_from_snapshot() {
472 use std::io::Read;
473 let compressed = oxicode_catalog::snapshot_gzip_bytes();
477 let mut decoder = flate2::read::GzDecoder::new(compressed);
478 let mut json = String::new();
479 decoder.read_to_string(&mut json).unwrap();
480 let catalog: crate::catalog::MdCatalog = serde_json::from_str(&json).unwrap();
481 let meta = crate::catalog::ProductMeta::builtin();
482 let (providers, models) = crate::catalog::materialize(&catalog, &meta, &Default::default());
483 let mut entries: Vec<super::ModelEntry> = Vec::new();
485 for model_list in models.values() {
486 for bm in model_list {
487 entries.push(super::ModelEntry::from(bm));
488 }
489 }
490 assert_eq!(entries.len(), 5277, "expected 5277 models");
491 assert_eq!(providers.len(), 145, "expected 145 providers");
492 for e in &entries {
494 assert!(
495 matches!(
496 e.api,
497 Api::AnthropicMessages
498 | Api::OpenAiCompletions
499 | Api::OpenAiResponses
500 | Api::GoogleGenerativeAi
501 | Api::GoogleVertex
502 | Api::AzureOpenAiResponses
503 | Api::BedrockConverseStream
504 ),
505 "unexpected api for model {}/{}",
506 e.provider,
507 e.id
508 );
509 }
510 assert!(
512 entries.iter().any(|e| e.cost_input == 0.0),
513 "expected at least one free model"
514 );
515 }
516}