1use serde_json::Value;
2use std::collections::{HashMap, HashSet};
3use std::sync::OnceLock;
4
5use crate::error::{LiteLLMError, Result};
6
7#[derive(Debug, Clone)]
8pub struct ModelPricing {
9 pub input_cost_per_1k: Option<f64>,
10 pub output_cost_per_1k: Option<f64>,
11 pub cache_read_cost_per_1k: Option<f64>,
19 pub cache_creation_cost_per_1k: Option<f64>,
22 pub max_input_tokens: Option<u32>,
23 pub max_output_tokens: Option<u32>,
24 pub mode: Option<String>,
25 pub provider: Option<String>,
26 pub supports_function_calling: Option<bool>,
27 pub supports_tool_choice: Option<bool>,
28 pub supports_vision: Option<bool>,
29 pub supports_reasoning: Option<bool>,
30 pub supports_prompt_caching: Option<bool>,
31 pub supports_response_schema: Option<bool>,
32 pub supports_parallel_function_calling: Option<bool>,
33 pub supported_endpoints: Option<Vec<String>>,
34 pub supported_modalities: Option<Vec<String>>,
35}
36
37#[derive(Debug, Clone)]
38pub struct Registry {
39 pub models: HashMap<String, ModelPricing>,
40}
41
42impl Registry {
43 pub fn load_embedded() -> Result<Self> {
44 Self::from_json_str(include_str!("../data/model_prices_and_context_window.json"))
45 }
46
47 pub fn from_json_str(raw: &str) -> Result<Self> {
48 let json: Value = serde_json::from_str(raw)
49 .map_err(|e| LiteLLMError::Parse(format!("model registry: {e}")))?;
50 let mut models = HashMap::new();
51 let map = json
52 .as_object()
53 .ok_or_else(|| LiteLLMError::Parse("model registry not an object".into()))?;
54 for (name, entry) in map {
55 if name == "sample_spec" {
56 continue;
57 }
58 if let Some(obj) = entry.as_object() {
59 let input = obj
60 .get("input_cost_per_token")
61 .and_then(|v| v.as_f64())
62 .map(|v| v * 1000.0);
63 let output = obj
64 .get("output_cost_per_token")
65 .and_then(|v| v.as_f64())
66 .map(|v| v * 1000.0);
67 let cache_read = obj
68 .get("cache_read_input_token_cost")
69 .and_then(|v| v.as_f64())
70 .or_else(|| {
71 obj.get("input_cost_per_token_cache_hit")
72 .and_then(|v| v.as_f64())
73 })
74 .map(|v| v * 1000.0);
75 let cache_creation = obj
76 .get("cache_creation_input_token_cost")
77 .and_then(|v| v.as_f64())
78 .map(|v| v * 1000.0);
79 let max_input = obj
80 .get("max_input_tokens")
81 .or_else(|| obj.get("max_tokens"))
82 .and_then(|v| v.as_u64())
83 .and_then(|v| u32::try_from(v).ok());
84 let max_output = obj
85 .get("max_output_tokens")
86 .and_then(|v| v.as_u64())
87 .and_then(|v| u32::try_from(v).ok());
88 let mode = obj
89 .get("mode")
90 .and_then(|v| v.as_str())
91 .map(|s| s.to_string());
92 let provider = obj
93 .get("litellm_provider")
94 .and_then(|v| v.as_str())
95 .map(|s| s.to_string());
96 let bool_field = |field: &str| obj.get(field).and_then(Value::as_bool);
97 let string_list_field = |field: &str| {
98 obj.get(field).and_then(Value::as_array).map(|arr| {
99 arr.iter()
100 .filter_map(|v| v.as_str().map(|s| s.to_string()))
101 .collect::<Vec<_>>()
102 })
103 };
104 models.insert(
105 name.to_string(),
106 ModelPricing {
107 input_cost_per_1k: input,
108 output_cost_per_1k: output,
109 cache_read_cost_per_1k: cache_read,
110 cache_creation_cost_per_1k: cache_creation,
111 max_input_tokens: max_input,
112 max_output_tokens: max_output,
113 mode,
114 provider,
115 supports_function_calling: bool_field("supports_function_calling"),
116 supports_tool_choice: bool_field("supports_tool_choice"),
117 supports_vision: bool_field("supports_vision"),
118 supports_reasoning: bool_field("supports_reasoning"),
119 supports_prompt_caching: bool_field("supports_prompt_caching"),
120 supports_response_schema: bool_field("supports_response_schema"),
121 supports_parallel_function_calling: bool_field(
122 "supports_parallel_function_calling",
123 ),
124 supported_endpoints: string_list_field("supported_endpoints"),
125 supported_modalities: string_list_field("supported_modalities"),
126 },
127 );
128 }
129 }
130 Ok(Self { models })
131 }
132
133 pub fn get(&self, model: &str) -> Option<&ModelPricing> {
134 self.models.get(model)
135 }
136
137 pub fn estimate_cost(&self, model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
138 let pricing = self.models.get(model)?;
139 let input = pricing
140 .input_cost_per_1k
141 .map(|v| v * input_tokens as f64 / 1000.0)?;
142 let output = pricing
143 .output_cost_per_1k
144 .map(|v| v * output_tokens as f64 / 1000.0)?;
145 Some(input + output)
146 }
147
148 pub fn estimate_cost_with_cache(
187 &self,
188 model: &str,
189 input_tokens: u32,
190 output_tokens: u32,
191 cache_read_tokens: u32,
192 cache_creation_tokens: u32,
193 ) -> Option<f64> {
194 let pricing = self.models.get(model)?;
195 let input = pricing
196 .input_cost_per_1k
197 .map(|v| v * input_tokens as f64 / 1000.0)?;
198 let output = pricing
199 .output_cost_per_1k
200 .map(|v| v * output_tokens as f64 / 1000.0)?;
201 let cache_read_rate = pricing
202 .cache_read_cost_per_1k
203 .or(pricing.input_cost_per_1k)?;
204 let cache_creation_rate = pricing
205 .cache_creation_cost_per_1k
206 .or(pricing.input_cost_per_1k)?;
207 let cache_read = cache_read_rate * cache_read_tokens as f64 / 1000.0;
208 let cache_creation = cache_creation_rate * cache_creation_tokens as f64 / 1000.0;
209 Some(input + output + cache_read + cache_creation)
210 }
211}
212
213pub(crate) fn embedded_model_supports_output_config(model: &str) -> bool {
214 static SUPPORTED_MODELS: OnceLock<HashSet<String>> = OnceLock::new();
215
216 let supported = SUPPORTED_MODELS.get_or_init(|| {
217 let raw = include_str!("../data/model_prices_and_context_window.json");
218 let Ok(Value::Object(models)) = serde_json::from_str::<Value>(raw) else {
219 return HashSet::new();
220 };
221 models
222 .into_iter()
223 .filter_map(|(name, metadata)| {
224 metadata
225 .get("supports_output_config")
226 .and_then(Value::as_bool)
227 .unwrap_or(false)
228 .then(|| name.to_ascii_lowercase().replace('.', "-"))
229 })
230 .collect()
231 });
232
233 let normalized = model.to_ascii_lowercase().replace('.', "-");
234 if supported.contains(&normalized) {
235 return true;
236 }
237
238 let Some((base, suffix)) = normalized.rsplit_once('-') else {
239 return false;
240 };
241 suffix.len() == 8
242 && suffix.bytes().all(|byte| byte.is_ascii_digit())
243 && supported.contains(base)
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn loads_registry() {
252 let registry = Registry::load_embedded().unwrap();
253 assert!(!registry.models.is_empty());
254 }
255
256 #[test]
257 fn parses_registry_from_json_string() {
258 let registry = Registry::from_json_str(
259 r#"{
260 "sample_spec": {},
261 "test/model": {
262 "input_cost_per_token": 0.000001,
263 "output_cost_per_token": 0.000002,
264 "max_input_tokens": 4096,
265 "max_output_tokens": 1024,
266 "mode": "chat",
267 "litellm_provider": "test"
268 }
269 }"#,
270 )
271 .unwrap();
272
273 let model = registry.get("test/model").unwrap();
274 assert_eq!(model.input_cost_per_1k, Some(0.001));
275 assert_eq!(model.output_cost_per_1k, Some(0.002));
276 assert_eq!(model.max_input_tokens, Some(4096));
277 assert_eq!(model.max_output_tokens, Some(1024));
278 }
279
280 #[test]
281 fn parses_cache_costs_and_capability_flags() {
282 let registry = Registry::from_json_str(
283 r#"{
284 "sample_spec": {},
285 "test/cached": {
286 "input_cost_per_token": 0.000001,
287 "output_cost_per_token": 0.000005,
288 "cache_read_input_token_cost": 0.0000001,
289 "cache_creation_input_token_cost": 0.00000125,
290 "mode": "chat",
291 "litellm_provider": "test",
292 "supports_function_calling": true,
293 "supports_tool_choice": true,
294 "supports_vision": false,
295 "supports_reasoning": true,
296 "supports_prompt_caching": true,
297 "supports_response_schema": true,
298 "supports_parallel_function_calling": false,
299 "supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
300 "supported_modalities": ["text", "image"],
301 "some_unknown_future_field": {"nested": true}
302 }
303 }"#,
304 )
305 .unwrap();
306
307 let model = registry.get("test/cached").unwrap();
308 assert!((model.cache_read_cost_per_1k.unwrap() - 0.0001).abs() < 1e-12);
309 assert!((model.cache_creation_cost_per_1k.unwrap() - 0.00125).abs() < 1e-12);
310 assert_eq!(model.supports_function_calling, Some(true));
311 assert_eq!(model.supports_tool_choice, Some(true));
312 assert_eq!(model.supports_vision, Some(false));
313 assert_eq!(model.supports_reasoning, Some(true));
314 assert_eq!(model.supports_prompt_caching, Some(true));
315 assert_eq!(model.supports_response_schema, Some(true));
316 assert_eq!(model.supports_parallel_function_calling, Some(false));
317 assert_eq!(
318 model.supported_endpoints,
319 Some(vec![
320 "/v1/chat/completions".to_string(),
321 "/v1/responses".to_string()
322 ])
323 );
324 assert_eq!(
325 model.supported_modalities,
326 Some(vec!["text".to_string(), "image".to_string()])
327 );
328 }
329
330 #[test]
331 fn falls_back_to_cache_hit_field_when_cache_read_cost_is_absent() {
332 let registry = Registry::from_json_str(
336 r#"{
337 "sample_spec": {},
338 "test/cache-hit-only": {
339 "input_cost_per_token": 0.0000005,
340 "output_cost_per_token": 0.000002,
341 "input_cost_per_token_cache_hit": 0.00000014,
342 "mode": "chat",
343 "litellm_provider": "test"
344 }
345 }"#,
346 )
347 .unwrap();
348
349 let model = registry.get("test/cache-hit-only").unwrap();
350 assert!((model.cache_read_cost_per_1k.unwrap() - 0.00014).abs() < 1e-12);
351 }
352
353 #[test]
354 fn estimate_cost_with_cache_uses_published_cache_rates() {
355 let registry = Registry::from_json_str(
356 r#"{
357 "test/cached": {
358 "input_cost_per_token": 0.000001,
359 "output_cost_per_token": 0.000005,
360 "cache_read_input_token_cost": 0.0000001,
361 "cache_creation_input_token_cost": 0.00000125
362 }
363 }"#,
364 )
365 .unwrap();
366
367 let cost = registry
368 .estimate_cost_with_cache("test/cached", 1000, 1000, 1000, 1000)
369 .unwrap();
370 let expected = 0.001 + 0.005 + 0.0001 + 0.00125;
373 assert!((cost - expected).abs() < 1e-12);
374
375 let plain = registry.estimate_cost("test/cached", 1000, 1000).unwrap();
378 let with_zero_cache = registry
379 .estimate_cost_with_cache("test/cached", 1000, 1000, 0, 0)
380 .unwrap();
381 assert!((plain - with_zero_cache).abs() < 1e-12);
382 }
383
384 #[test]
385 fn estimate_cost_with_cache_falls_back_to_input_rate_when_uncached() {
386 let registry = Registry::from_json_str(
387 r#"{
388 "test/uncached": {
389 "input_cost_per_token": 0.000002,
390 "output_cost_per_token": 0.000004
391 }
392 }"#,
393 )
394 .unwrap();
395
396 let cost = registry
399 .estimate_cost_with_cache("test/uncached", 0, 0, 500, 500)
400 .unwrap();
401 let input_rate = registry
402 .get("test/uncached")
403 .unwrap()
404 .input_cost_per_1k
405 .unwrap();
406 let expected = input_rate * 500.0 / 1000.0 * 2.0;
407 assert!((cost - expected).abs() < 1e-12);
408 }
409
410 #[test]
414 fn embedded_registry_capability_flags_are_populated() {
415 let registry = Registry::load_embedded().unwrap();
416
417 let function_calling_models = registry
418 .models
419 .values()
420 .filter(|m| m.supports_function_calling == Some(true))
421 .count();
422 assert!(
423 function_calling_models > 100,
424 "expected broad supports_function_calling coverage, found {function_calling_models}"
425 );
426
427 let cache_priced_models = registry
428 .models
429 .values()
430 .filter(|m| m.cache_read_cost_per_1k.is_some())
431 .count();
432 assert!(
433 cache_priced_models > 100,
434 "expected broad cache-read pricing coverage, found {cache_priced_models}"
435 );
436
437 let vision_models = registry
438 .models
439 .values()
440 .filter(|m| m.supports_vision == Some(true))
441 .count();
442 assert!(
443 vision_models > 100,
444 "expected broad supports_vision coverage, found {vision_models}"
445 );
446
447 let with_modalities = registry
448 .models
449 .values()
450 .filter(|m| {
451 m.supported_modalities
452 .as_ref()
453 .is_some_and(|v| !v.is_empty())
454 })
455 .count();
456 assert!(
457 with_modalities > 0,
458 "expected at least one model with supported_modalities populated"
459 );
460 }
461
462 #[test]
463 fn token_counts_beyond_u32_are_dropped_not_truncated() {
464 let registry = Registry::from_json_str(
465 r#"{
466 "sample_spec": {},
467 "test/overflow": {
468 "max_input_tokens": 5000000000,
469 "max_output_tokens": 4294967296,
470 "mode": "chat"
471 }
472 }"#,
473 )
474 .unwrap();
475
476 let model = registry.get("test/overflow").unwrap();
477 assert_eq!(model.max_input_tokens, None);
478 assert_eq!(model.max_output_tokens, None);
479 }
480
481 #[test]
482 fn embedded_registry_retains_metadata_coverage() {
483 let registry = Registry::load_embedded().unwrap();
484 let priced_models = registry
485 .models
486 .values()
487 .filter(|model| model.output_cost_per_1k.is_some())
488 .count();
489 let models_with_context = registry
490 .models
491 .values()
492 .filter(|model| model.max_input_tokens.is_some())
493 .count();
494
495 assert!(
496 priced_models > 1000,
497 "expected broad completion-pricing coverage, found {priced_models} models"
498 );
499 assert!(
500 models_with_context > 1000,
501 "expected broad context-window coverage, found {models_with_context} models"
502 );
503 }
504
505 #[test]
506 fn embedded_capabilities_drive_anthropic_output_config_support() {
507 assert!(embedded_model_supports_output_config("claude-sonnet-5"));
508 assert!(embedded_model_supports_output_config("claude-opus-4.8"));
509 assert!(!embedded_model_supports_output_config(
510 "claude-3-7-sonnet-20250219"
511 ));
512 assert!(!embedded_model_supports_output_config(
513 "claude-3-5-sonnet-20241022"
514 ));
515 }
516
517 #[test]
518 fn embedded_capabilities_retry_without_one_trailing_date_token() {
519 assert!(embedded_model_supports_output_config(
520 "claude-opus-4.8-20260315"
521 ));
522 }
523
524 #[test]
525 fn embedded_capabilities_do_not_promote_unflagged_dated_models() {
526 assert!(!embedded_model_supports_output_config(
527 "claude-haiku-4-5-20260711"
528 ));
529 }
530
531 #[test]
532 fn embedded_capabilities_leave_non_dated_misses_unsupported() {
533 assert!(!embedded_model_supports_output_config(
534 "claude-sonnet-5-latest"
535 ));
536 }
537
538 #[test]
539 fn embedded_capabilities_strip_at_most_one_trailing_date_token() {
540 assert!(!embedded_model_supports_output_config(
541 "claude-sonnet-5-20260710-20260711"
542 ));
543 }
544}