Skip to main content

litellm_rust/
registry.rs

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    /// Cost per 1k tokens read from the prompt cache, converted from the
12    /// upstream `cache_read_input_token_cost` per-token field using the same
13    /// per-token -> per-1k convention as `input_cost_per_1k`. When that field
14    /// is absent, the equivalent `input_cost_per_token_cache_hit` field is
15    /// read instead. No current registry entry needs this fallback (entries
16    /// that publish the cache-hit field also publish the cache-read field);
17    /// it guards against an upstream regression that drops the latter.
18    pub cache_read_cost_per_1k: Option<f64>,
19    /// Cost per 1k tokens written to the prompt cache, converted from the
20    /// upstream `cache_creation_input_token_cost` per-token field.
21    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    /// Estimates cost including prompt-cache read and cache-creation tokens.
149    ///
150    /// Cache read tokens are priced at `cache_read_cost_per_1k` and cache
151    /// creation tokens at `cache_creation_cost_per_1k` when the model
152    /// publishes that pricing. When a model has no cache-specific pricing,
153    /// cache tokens fall back to being billed at the model's normal input
154    /// rate (`input_cost_per_1k`) rather than being treated as free, since
155    /// providers bill uncached-equivalent tokens at the input rate whenever
156    /// they don't publish a discounted cache rate.
157    ///
158    /// Cache-creation tokens are priced at the base cache-write rate, which
159    /// is the 5-minute TTL rate. Writes with a 1-hour TTL and long-context
160    /// tier rates (for example `*_above_200k_tokens` prices) are not modeled,
161    /// so the estimate is low for those requests.
162    ///
163    /// # Token accounting contract
164    ///
165    /// `input_tokens`, `cache_read_tokens`, and `cache_creation_tokens` must
166    /// be disjoint counts that sum to the total prompt tokens billed —
167    /// `input_tokens` must **exclude** any tokens already counted in
168    /// `cache_read_tokens` or `cache_creation_tokens`.
169    ///
170    /// Providers report prompt tokens in different shapes, and the crate's
171    /// [`Usage`](crate::types::Usage) `prompt_tokens` keeps each provider's
172    /// shape as reported:
173    ///
174    /// - **Anthropic**: `input_tokens` already excludes
175    ///   `cache_read_input_tokens` and `cache_creation_input_tokens`, which
176    ///   are separate fields. Pass these counts directly.
177    /// - **OpenAI-compatible**: `prompt_tokens` (or `input_tokens` on the
178    ///   Responses API) includes cached tokens; `cached_tokens` in the
179    ///   details object is a subset of it, not an additive count.
180    /// - **Gemini**: `promptTokenCount` includes `cachedContentTokenCount`.
181    ///
182    /// For OpenAI-compatible and Gemini usage, subtract the cached count from
183    /// the prompt count before you pass it as `input_tokens`. If you do not,
184    /// the cached tokens are billed twice: once at the input rate and once
185    /// at the cache rate.
186    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        // No current registry entry publishes only
333        // `input_cost_per_token_cache_hit`; this guards the fallback against an
334        // upstream regression that drops `cache_read_input_token_cost`.
335        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        // input: 1000/1000 * 0.001 = 0.001, output: 1000/1000 * 0.005 = 0.005
371        // cache_read: 1000/1000 * 0.0001 = 0.0001, cache_creation: 1000/1000 * 0.00125 = 0.00125
372        let expected = 0.001 + 0.005 + 0.0001 + 0.00125;
373        assert!((cost - expected).abs() < 1e-12);
374
375        // Without any cache tokens, the cache-aware estimate must match the
376        // plain estimate exactly.
377        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        // No cache-specific pricing published: cache read/creation tokens
397        // must be billed at the model's normal input rate.
398        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    /// Structural check that cache-pricing and capability-flag parsing wires
411    /// through to real entries in the embedded registry, without pinning to
412    /// specific model names that may rotate on refresh.
413    #[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}