use crate::error::BudgetError;
use serde_json::Value;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Usage {
pub input_tokens: u64,
pub cached_input_tokens: u64,
pub cache_write_tokens: u64,
pub output_tokens: u64,
}
fn n(v: &Value, key: &str) -> u64 {
v.get(key).and_then(Value::as_u64).unwrap_or(0)
}
impl Usage {
pub fn new(input_tokens: u64, output_tokens: u64) -> Self {
Self { input_tokens, output_tokens, ..Self::default() }
}
pub fn fresh_input_tokens(&self) -> u64 {
self.input_tokens
.saturating_sub(self.cached_input_tokens)
.saturating_sub(self.cache_write_tokens)
}
pub fn from_json(text: &str) -> Result<Self, BudgetError> {
let v: Value = serde_json::from_str(text)
.map_err(|e| BudgetError::InvalidConfig(format!("usage JSON: {e}")))?;
Self::from_value(&v)
}
pub fn from_value(v: &Value) -> Result<Self, BudgetError> {
let u = v
.get("usage")
.or_else(|| v.get("usageMetadata"))
.or_else(|| v.get("usage_metadata"))
.unwrap_or(v);
if u.get("prompt_tokens").is_some() {
let cached = u.get("prompt_tokens_details").map_or(0, |d| n(d, "cached_tokens"));
let input = n(u, "prompt_tokens");
return Ok(Self {
input_tokens: input,
cached_input_tokens: cached.min(input),
cache_write_tokens: 0,
output_tokens: n(u, "completion_tokens"),
});
}
if u.get("promptTokenCount").is_some() || u.get("candidatesTokenCount").is_some() {
let input = n(u, "promptTokenCount");
return Ok(Self {
input_tokens: input,
cached_input_tokens: n(u, "cachedContentTokenCount").min(input),
cache_write_tokens: 0,
output_tokens: n(u, "candidatesTokenCount").saturating_add(n(u, "thoughtsTokenCount")),
});
}
if u.get("input_tokens").is_some() || u.get("output_tokens").is_some() {
if let Some(details) = u.get("input_tokens_details") {
let input = n(u, "input_tokens");
return Ok(Self {
input_tokens: input,
cached_input_tokens: n(details, "cached_tokens").min(input),
cache_write_tokens: 0,
output_tokens: n(u, "output_tokens"),
});
}
let read = n(u, "cache_read_input_tokens");
let write = n(u, "cache_creation_input_tokens");
return Ok(Self {
input_tokens: n(u, "input_tokens").saturating_add(read).saturating_add(write),
cached_input_tokens: read,
cache_write_tokens: write,
output_tokens: n(u, "output_tokens"),
});
}
Err(BudgetError::InvalidConfig(
"no usage block found (expected OpenAI, Anthropic or Gemini usage fields)".into(),
))
}
}
#[cfg(feature = "async-openai")]
impl From<&async_openai::types::chat::CompletionUsage> for Usage {
fn from(u: &async_openai::types::chat::CompletionUsage) -> Self {
let input = u64::from(u.prompt_tokens);
let cached = u
.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
.map_or(0, u64::from);
Self {
input_tokens: input,
cached_input_tokens: cached.min(input),
cache_write_tokens: 0,
output_tokens: u64::from(u.completion_tokens),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pricing::PriceTable;
#[test]
fn openai_chat_cached_tokens_are_a_subset() {
let u = Usage::from_json(
r#"{"id":"x","usage":{"prompt_tokens":2006,"completion_tokens":300,"total_tokens":2306,
"prompt_tokens_details":{"cached_tokens":1920},"completion_tokens_details":{"reasoning_tokens":0}}}"#,
)
.unwrap();
assert_eq!(u, Usage { input_tokens: 2006, cached_input_tokens: 1920, cache_write_tokens: 0, output_tokens: 300 });
assert_eq!(u.fresh_input_tokens(), 86);
}
#[test]
fn openai_responses_api_is_not_double_counted() {
let u = Usage::from_json(
r#"{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":800},
"output_tokens":10,"output_tokens_details":{"reasoning_tokens":4},"total_tokens":1010}}"#,
)
.unwrap();
assert_eq!(u.input_tokens, 1000);
assert_eq!(u.cached_input_tokens, 800);
assert_eq!(u.fresh_input_tokens(), 200);
}
#[test]
fn anthropic_cache_counts_are_added() {
let u = Usage::from_json(
r#"{"input_tokens":50,"output_tokens":20,"cache_creation_input_tokens":1000,"cache_read_input_tokens":3000}"#,
)
.unwrap();
assert_eq!(u.input_tokens, 4050);
assert_eq!(u.fresh_input_tokens(), 50);
assert_eq!(u.cache_write_tokens, 1000);
}
#[test]
fn gemini_counts_thinking_as_output() {
let u = Usage::from_json(
r#"{"usageMetadata":{"promptTokenCount":500,"candidatesTokenCount":40,"thoughtsTokenCount":60,
"cachedContentTokenCount":100,"totalTokenCount":600}}"#,
)
.unwrap();
assert_eq!(u, Usage { input_tokens: 500, cached_input_tokens: 100, cache_write_tokens: 0, output_tokens: 100 });
}
#[test]
fn bad_input_is_an_error_not_a_panic() {
assert!(Usage::from_json("not json").is_err());
assert!(Usage::from_json(r#"{"choices":[]}"#).is_err());
let u = Usage::from_json(r#"{"prompt_tokens":10,"completion_tokens":1,"prompt_tokens_details":{"cached_tokens":99}}"#).unwrap();
assert_eq!(u.fresh_input_tokens(), 0);
let u = Usage::from_json(r#"{"prompt_tokens":-5,"completion_tokens":"x"}"#).unwrap();
assert_eq!(u, Usage::default());
}
#[test]
fn priced_with_cache_discounts() {
let p = PriceTable::builtin().require("claude-sonnet-4-5").unwrap();
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 };
let expected = 3.0 + 2.0 * 0.3 + 3.75 + 15.0;
assert!((p.cost_usage(&u) - expected).abs() < 1e-9, "{}", p.cost_usage(&u));
assert!((p.cost_usage(&Usage::new(1_000, 1_000)) - p.cost(1_000, 1_000)).abs() < 1e-15);
}
#[cfg(feature = "async-openai")]
#[test]
fn converts_async_openai_usage() {
let json = r#"{"prompt_tokens":2006,"completion_tokens":300,"total_tokens":2306,"prompt_tokens_details":{"cached_tokens":1920}}"#;
let ao: async_openai::types::chat::CompletionUsage = serde_json::from_str(json).unwrap();
assert_eq!(Usage::from(&ao), Usage::from_json(json).unwrap());
}
}