Skip to main content

ag_ui/
token_usage.rs

1//! Per-model token accounting carried by `RUN_FINISHED` and `RUN_ERROR`.
2
3use serde::{Deserialize, Serialize};
4
5/// A numeric-only token usage summary for one `(provider, model)` pair.
6///
7/// Deliberately carries nothing identifying or content-bearing — no prompts,
8/// completions, message text, or thread/run/user ids. Only provider and model
9/// labels plus counts, so usage can be logged and aggregated in places where
10/// conversation content must not go.
11///
12/// Every count is optional and every count is a non-negative integer: `None`
13/// means *the provider did not report it*, which is distinct from zero.
14#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
17#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
18pub struct TokenUsage {
19    /// The inference provider, for example `"anthropic"`.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub provider: Option<String>,
22    /// The model id, for example `"claude-opus-5"`.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub model: Option<String>,
25    /// Tokens consumed by the prompt.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub input_tokens: Option<u64>,
28    /// Tokens produced by the model.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub output_tokens: Option<u64>,
31    /// Total tokens, as reported by the provider (not necessarily the sum of
32    /// the other fields).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub total_tokens: Option<u64>,
35    /// Output tokens spent on reasoning.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub reasoning_tokens: Option<u64>,
38    /// Input tokens served from the provider's prompt cache.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub cached_input_tokens: Option<u64>,
41}
42
43impl TokenUsage {
44    /// An empty usage entry.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Whether any count is present. A labels-only entry reports nothing and
50    /// should be omitted rather than emitted.
51    pub const fn has_counts(&self) -> bool {
52        self.input_tokens.is_some()
53            || self.output_tokens.is_some()
54            || self.total_tokens.is_some()
55            || self.reasoning_tokens.is_some()
56            || self.cached_input_tokens.is_some()
57    }
58}
59
60/// Sums per-call [`TokenUsage`] entries into one entry per `(provider, model)`
61/// pair, in order of first appearance.
62///
63/// A count stays `None` when no member of a group reported it, keeping "not
64/// reported" distinct from zero.
65///
66/// ```
67/// # use ag_ui::{TokenUsage, aggregate_token_usage};
68/// let calls = vec![
69///     TokenUsage { model: Some("m".into()), input_tokens: Some(10), ..Default::default() },
70///     TokenUsage { model: Some("m".into()), input_tokens: Some(5), ..Default::default() },
71/// ];
72/// let total = aggregate_token_usage(&calls);
73/// assert_eq!(total.len(), 1);
74/// assert_eq!(total[0].input_tokens, Some(15));
75/// assert_eq!(total[0].output_tokens, None);
76/// ```
77pub fn aggregate_token_usage(entries: &[TokenUsage]) -> Vec<TokenUsage> {
78    let mut grouped: Vec<TokenUsage> = Vec::new();
79
80    for entry in entries {
81        let index = match grouped
82            .iter()
83            .position(|g| g.provider == entry.provider && g.model == entry.model)
84        {
85            Some(index) => index,
86            None => {
87                grouped.push(TokenUsage {
88                    provider: entry.provider.clone(),
89                    model: entry.model.clone(),
90                    ..Default::default()
91                });
92                grouped.len() - 1
93            }
94        };
95
96        let target = &mut grouped[index];
97        add_into(&mut target.input_tokens, entry.input_tokens);
98        add_into(&mut target.output_tokens, entry.output_tokens);
99        add_into(&mut target.total_tokens, entry.total_tokens);
100        add_into(&mut target.reasoning_tokens, entry.reasoning_tokens);
101        add_into(&mut target.cached_input_tokens, entry.cached_input_tokens);
102    }
103
104    grouped
105}
106
107/// Adds `value` into `target`, leaving `target` untouched when nothing was
108/// reported. Saturating, so a hostile producer cannot panic the aggregation.
109fn add_into(target: &mut Option<u64>, value: Option<u64>) {
110    if let Some(value) = value {
111        *target = Some(target.unwrap_or(0).saturating_add(value));
112    }
113}