1use crate::error::BudgetError;
32use serde_json::Value;
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41pub struct Usage {
42 pub input_tokens: u64,
44 pub cached_input_tokens: u64,
46 pub cache_write_tokens: u64,
48 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 pub fn new(input_tokens: u64, output_tokens: u64) -> Self {
59 Self { input_tokens, output_tokens, ..Self::default() }
60 }
61
62 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 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 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 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 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 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 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 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 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 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 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 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}