Skip to main content

llm_budget/
usage.rs

1//! Charge exactly what the provider reported.
2//!
3//! Every API returns a `usage` block with the real token counts, but each
4//! provider counts prompt-cache tokens differently, and getting that wrong
5//! either double-charges or under-charges:
6//!
7//! | API | Prompt count | Cache reads | Cache writes |
8//! |---|---|---|---|
9//! | OpenAI Chat Completions (`prompt_tokens`) | includes cached | `prompt_tokens_details.cached_tokens` (a subset) | not reported |
10//! | OpenAI Responses (`input_tokens` + `input_tokens_details`) | includes cached | `input_tokens_details.cached_tokens` (a subset) | not reported |
11//! | Anthropic Messages (`input_tokens`) | excludes cache | `cache_read_input_tokens` (extra) | `cache_creation_input_tokens` (extra) |
12//! | Google Gemini (`usageMetadata.promptTokenCount`) | includes cached | `cachedContentTokenCount` (a subset) | not reported |
13//!
14//! [`Usage::from_json`] reads any of these (the whole response or just the
15//! usage object) into one normalised [`Usage`], and
16//! [`ModelPrice::cost_usage`](crate::pricing::ModelPrice::cost_usage) prices
17//! it with the cache discounts.
18//!
19//! ```
20//! use llm_budget::usage::Usage;
21//!
22//! // An Anthropic response: 100 fresh input tokens, 2,000 read from cache.
23//! let body = r#"{"usage": {"input_tokens": 100, "output_tokens": 50,
24//!                "cache_read_input_tokens": 2000, "cache_creation_input_tokens": 0}}"#;
25//! let u = Usage::from_json(body)?;
26//! assert_eq!(u.input_tokens, 2_100);          // all prompt tokens
27//! assert_eq!(u.cached_input_tokens, 2_000);   // of which cheap cache reads
28//! # Ok::<(), llm_budget::BudgetError>(())
29//! ```
30
31use crate::error::BudgetError;
32use serde_json::Value;
33
34/// Token counts of one call, normalised across providers.
35///
36/// `input_tokens` is every prompt token, including the ones served from or
37/// written to the cache; `cached_input_tokens` and `cache_write_tokens` say how
38/// many of them get the cache prices instead of the normal input price.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41pub struct Usage {
42    /// All prompt tokens, cached or not.
43    pub input_tokens: u64,
44    /// Prompt tokens read from the provider's cache (part of `input_tokens`).
45    pub cached_input_tokens: u64,
46    /// Prompt tokens written to the provider's cache (part of `input_tokens`).
47    pub cache_write_tokens: u64,
48    /// Completion tokens, including any reasoning or "thinking" tokens.
49    pub output_tokens: u64,
50}
51
52fn n(v: &Value, key: &str) -> u64 {
53    v.get(key).and_then(Value::as_u64).unwrap_or(0)
54}
55
56impl Usage {
57    /// Plain counts with no caching.
58    pub fn new(input_tokens: u64, output_tokens: u64) -> Self {
59        Self { input_tokens, output_tokens, ..Self::default() }
60    }
61
62    /// Prompt tokens billed at the normal input price.
63    pub fn fresh_input_tokens(&self) -> u64 {
64        self.input_tokens
65            .saturating_sub(self.cached_input_tokens)
66            .saturating_sub(self.cache_write_tokens)
67    }
68
69    /// Parse a provider response body, or just its usage object, from JSON text.
70    ///
71    /// # Errors
72    /// [`BudgetError::InvalidConfig`] if the text is not JSON or has no
73    /// recognisable usage block.
74    pub fn from_json(text: &str) -> Result<Self, BudgetError> {
75        let v: Value = serde_json::from_str(text)
76            .map_err(|e| BudgetError::InvalidConfig(format!("usage JSON: {e}")))?;
77        Self::from_value(&v)
78    }
79
80    /// Same as [`from_json`](Self::from_json) for an already parsed value.
81    ///
82    /// # Errors
83    /// [`BudgetError::InvalidConfig`] if there is no recognisable usage block.
84    pub fn from_value(v: &Value) -> Result<Self, BudgetError> {
85        let u = v
86            .get("usage")
87            .or_else(|| v.get("usageMetadata"))
88            .or_else(|| v.get("usage_metadata"))
89            .unwrap_or(v);
90        // OpenAI Chat Completions
91        if u.get("prompt_tokens").is_some() {
92            let cached = u.get("prompt_tokens_details").map_or(0, |d| n(d, "cached_tokens"));
93            let input = n(u, "prompt_tokens");
94            return Ok(Self {
95                input_tokens: input,
96                cached_input_tokens: cached.min(input),
97                cache_write_tokens: 0,
98                output_tokens: n(u, "completion_tokens"),
99            });
100        }
101        // Google Gemini
102        if u.get("promptTokenCount").is_some() || u.get("candidatesTokenCount").is_some() {
103            let input = n(u, "promptTokenCount");
104            return Ok(Self {
105                input_tokens: input,
106                cached_input_tokens: n(u, "cachedContentTokenCount").min(input),
107                cache_write_tokens: 0,
108                // thinking tokens are billed as output
109                output_tokens: n(u, "candidatesTokenCount").saturating_add(n(u, "thoughtsTokenCount")),
110            });
111        }
112        if u.get("input_tokens").is_some() || u.get("output_tokens").is_some() {
113            // OpenAI Responses API: input includes the cached subset.
114            if let Some(details) = u.get("input_tokens_details") {
115                let input = n(u, "input_tokens");
116                return Ok(Self {
117                    input_tokens: input,
118                    cached_input_tokens: n(details, "cached_tokens").min(input),
119                    cache_write_tokens: 0,
120                    output_tokens: n(u, "output_tokens"),
121                });
122            }
123            // Anthropic: cache reads and writes come on top of input_tokens.
124            let read = n(u, "cache_read_input_tokens");
125            let write = n(u, "cache_creation_input_tokens");
126            return Ok(Self {
127                input_tokens: n(u, "input_tokens").saturating_add(read).saturating_add(write),
128                cached_input_tokens: read,
129                cache_write_tokens: write,
130                output_tokens: n(u, "output_tokens"),
131            });
132        }
133        Err(BudgetError::InvalidConfig(
134            "no usage block found (expected OpenAI, Anthropic or Gemini usage fields)".into(),
135        ))
136    }
137}
138
139#[cfg(feature = "async-openai")]
140impl From<&async_openai::types::chat::CompletionUsage> for Usage {
141    fn from(u: &async_openai::types::chat::CompletionUsage) -> Self {
142        let input = u64::from(u.prompt_tokens);
143        let cached = u
144            .prompt_tokens_details
145            .as_ref()
146            .and_then(|d| d.cached_tokens)
147            .map_or(0, u64::from);
148        Self {
149            input_tokens: input,
150            cached_input_tokens: cached.min(input),
151            cache_write_tokens: 0,
152            output_tokens: u64::from(u.completion_tokens),
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::pricing::PriceTable;
161
162    #[test]
163    fn openai_chat_cached_tokens_are_a_subset() {
164        let u = Usage::from_json(
165            r#"{"id":"x","usage":{"prompt_tokens":2006,"completion_tokens":300,"total_tokens":2306,
166                "prompt_tokens_details":{"cached_tokens":1920},"completion_tokens_details":{"reasoning_tokens":0}}}"#,
167        )
168        .unwrap();
169        assert_eq!(u, Usage { input_tokens: 2006, cached_input_tokens: 1920, cache_write_tokens: 0, output_tokens: 300 });
170        assert_eq!(u.fresh_input_tokens(), 86);
171    }
172
173    #[test]
174    fn openai_responses_api_is_not_double_counted() {
175        // Same field names as Anthropic, opposite meaning: here input includes cache.
176        let u = Usage::from_json(
177            r#"{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":800},
178                "output_tokens":10,"output_tokens_details":{"reasoning_tokens":4},"total_tokens":1010}}"#,
179        )
180        .unwrap();
181        assert_eq!(u.input_tokens, 1000);
182        assert_eq!(u.cached_input_tokens, 800);
183        assert_eq!(u.fresh_input_tokens(), 200);
184    }
185
186    #[test]
187    fn anthropic_cache_counts_are_added() {
188        let u = Usage::from_json(
189            r#"{"input_tokens":50,"output_tokens":20,"cache_creation_input_tokens":1000,"cache_read_input_tokens":3000}"#,
190        )
191        .unwrap();
192        assert_eq!(u.input_tokens, 4050);
193        assert_eq!(u.fresh_input_tokens(), 50);
194        assert_eq!(u.cache_write_tokens, 1000);
195    }
196
197    #[test]
198    fn gemini_counts_thinking_as_output() {
199        let u = Usage::from_json(
200            r#"{"usageMetadata":{"promptTokenCount":500,"candidatesTokenCount":40,"thoughtsTokenCount":60,
201                "cachedContentTokenCount":100,"totalTokenCount":600}}"#,
202        )
203        .unwrap();
204        assert_eq!(u, Usage { input_tokens: 500, cached_input_tokens: 100, cache_write_tokens: 0, output_tokens: 100 });
205    }
206
207    #[test]
208    fn bad_input_is_an_error_not_a_panic() {
209        assert!(Usage::from_json("not json").is_err());
210        assert!(Usage::from_json(r#"{"choices":[]}"#).is_err());
211        // a cached count larger than the prompt is clamped
212        let u = Usage::from_json(r#"{"prompt_tokens":10,"completion_tokens":1,"prompt_tokens_details":{"cached_tokens":99}}"#).unwrap();
213        assert_eq!(u.fresh_input_tokens(), 0);
214        let u = Usage::from_json(r#"{"prompt_tokens":-5,"completion_tokens":"x"}"#).unwrap();
215        assert_eq!(u, Usage::default());
216    }
217
218    #[test]
219    fn priced_with_cache_discounts() {
220        // claude-sonnet-4-5: $3 in, $15 out, $0.30 cache read, $3.75 cache write per million.
221        let p = PriceTable::builtin().require("claude-sonnet-4-5").unwrap();
222        let u = Usage { input_tokens: 1_000_000 + 2_000_000 + 1_000_000, cached_input_tokens: 2_000_000, cache_write_tokens: 1_000_000, output_tokens: 1_000_000 };
223        let expected = 3.0 + 2.0 * 0.3 + 3.75 + 15.0;
224        assert!((p.cost_usage(&u) - expected).abs() < 1e-9, "{}", p.cost_usage(&u));
225        // without caching it is the plain price
226        assert!((p.cost_usage(&Usage::new(1_000, 1_000)) - p.cost(1_000, 1_000)).abs() < 1e-15);
227    }
228
229    #[cfg(feature = "async-openai")]
230    #[test]
231    fn converts_async_openai_usage() {
232        let json = r#"{"prompt_tokens":2006,"completion_tokens":300,"total_tokens":2306,"prompt_tokens_details":{"cached_tokens":1920}}"#;
233        let ao: async_openai::types::chat::CompletionUsage = serde_json::from_str(json).unwrap();
234        assert_eq!(Usage::from(&ao), Usage::from_json(json).unwrap());
235    }
236}