Skip to main content

scx_forge_agent/
usage.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! Token-usage accounting summed across the whole optimization run, plus the
4//! one-line footer (`prompt:24.3k  tokens:515`) printed at the bottom of the
5//! output. The footer shape mirrors boro's progress UI.
6//!
7//! `prompt` is the cumulative input-token count across every model request the
8//! run made (each tool-loop iteration resends the growing message history, so it
9//! grows fast); `completion` is the cumulative output tokens. `cache_read` /
10//! `cache_creation` are shown only when nonzero (prompt-cache hits/writes).
11
12use serde_json::{json, Value};
13
14#[derive(Debug, Default, Clone, Copy)]
15pub struct Usage {
16    pub prompt: u64,
17    pub completion: u64,
18    pub cache_read: u64,
19    pub cache_creation: u64,
20}
21
22impl Usage {
23    pub fn add(&mut self, other: &Usage) {
24        self.prompt += other.prompt;
25        self.completion += other.completion;
26        self.cache_read += other.cache_read;
27        self.cache_creation += other.cache_creation;
28    }
29
30    /// Parse an OpenAI-style `usage` object:
31    /// `{ prompt_tokens, completion_tokens, prompt_tokens_details: { cached_tokens } }`.
32    pub fn from_openai(usage: &Value) -> Usage {
33        let g = |k: &str| usage.get(k).and_then(|v| v.as_u64()).unwrap_or(0);
34        let cache_read = usage
35            .get("prompt_tokens_details")
36            .and_then(|d| d.get("cached_tokens"))
37            .and_then(|v| v.as_u64())
38            .unwrap_or(0);
39        Usage {
40            prompt: g("prompt_tokens"),
41            completion: g("completion_tokens"),
42            cache_read,
43            cache_creation: 0,
44        }
45    }
46
47    /// Parse an Anthropic-style `usage` object (the `claude` CLI result event):
48    /// `{ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }`.
49    pub fn from_anthropic(usage: &Value) -> Usage {
50        let g = |k: &str| usage.get(k).and_then(|v| v.as_u64()).unwrap_or(0);
51        Usage {
52            prompt: g("input_tokens"),
53            completion: g("output_tokens"),
54            cache_read: g("cache_read_input_tokens"),
55            cache_creation: g("cache_creation_input_tokens"),
56        }
57    }
58
59    /// Parse a `cursor-agent` result-event `usage` object (camelCase):
60    /// `{ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }`.
61    pub fn from_cursor(usage: &Value) -> Usage {
62        let g = |k: &str| usage.get(k).and_then(|v| v.as_u64()).unwrap_or(0);
63        Usage {
64            prompt: g("inputTokens"),
65            completion: g("outputTokens"),
66            cache_read: g("cacheReadTokens"),
67            cache_creation: g("cacheWriteTokens"),
68        }
69    }
70
71    /// Parse a `usage` object that may be in either OpenAI or Anthropic shape
72    /// (used for subprocess backends whose event schema is less certain).
73    pub fn from_any(usage: &Value) -> Usage {
74        if usage.get("prompt_tokens").is_some() || usage.get("completion_tokens").is_some() {
75            Usage::from_openai(usage)
76        } else {
77            Usage::from_anthropic(usage)
78        }
79    }
80
81    pub fn to_json(self) -> Value {
82        json!({
83            "prompt": self.prompt,
84            "completion": self.completion,
85            "cache_read": self.cache_read,
86            "cache_creation": self.cache_creation,
87        })
88    }
89
90    /// One-line footer: `prompt:24.3k  tokens:515` (cache_r/cache_w shown only
91    /// when nonzero).
92    pub fn footer_line(&self) -> String {
93        let mut s = format!("prompt:{}", fmt_tokens(self.prompt));
94        if self.cache_read > 0 || self.cache_creation > 0 {
95            s.push_str(&format!(
96                "  cache_r:{}  cache_w:{}",
97                fmt_tokens(self.cache_read),
98                fmt_tokens(self.cache_creation),
99            ));
100        }
101        s.push_str(&format!("  tokens:{}", fmt_tokens(self.completion)));
102        s
103    }
104}
105
106/// Compact token count: exact below 1000, else scaled to k/M/G with one decimal
107/// (a trailing `.0` is dropped). Matches boro's footer formatting.
108fn fmt_tokens(n: u64) -> String {
109    const K: f64 = 1000.0;
110    if n < 1000 {
111        return n.to_string();
112    }
113    if n < 1_000_000 {
114        return fmt_scaled(n as f64 / K, "k");
115    }
116    if n < 1_000_000_000 {
117        return fmt_scaled(n as f64 / (K * K), "M");
118    }
119    fmt_scaled(n as f64 / (K * K * K), "G")
120}
121
122fn fmt_scaled(value: f64, suffix: &str) -> String {
123    let t = (value * 10.0).round() / 10.0;
124    if (t - t.floor()).abs() < 0.001 {
125        format!("{}{}", t as u64, suffix)
126    } else {
127        format!("{t:.1}{suffix}")
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use serde_json::json;
135
136    #[test]
137    fn footer_shape_matches_boro() {
138        let u = Usage {
139            prompt: 1234,
140            completion: 56,
141            ..Default::default()
142        };
143        assert_eq!(u.footer_line(), "prompt:1.2k  tokens:56");
144        let u = Usage {
145            prompt: 1234,
146            completion: 56,
147            cache_read: 90,
148            cache_creation: 78,
149        };
150        assert_eq!(
151            u.footer_line(),
152            "prompt:1.2k  cache_r:90  cache_w:78  tokens:56"
153        );
154    }
155
156    #[test]
157    fn fmt_tokens_scales() {
158        assert_eq!(fmt_tokens(515), "515");
159        assert_eq!(fmt_tokens(999), "999");
160        assert_eq!(fmt_tokens(24_300), "24.3k");
161        assert_eq!(fmt_tokens(2000), "2k"); // trailing .0 dropped
162        assert_eq!(fmt_tokens(1_500_000), "1.5M");
163    }
164
165    #[test]
166    fn add_sums_fields() {
167        let mut a = Usage {
168            prompt: 10,
169            completion: 1,
170            cache_read: 2,
171            cache_creation: 3,
172        };
173        a.add(&Usage {
174            prompt: 5,
175            completion: 4,
176            cache_read: 6,
177            cache_creation: 7,
178        });
179        assert_eq!(
180            (a.prompt, a.completion, a.cache_read, a.cache_creation),
181            (15, 5, 8, 10)
182        );
183    }
184
185    #[test]
186    fn parse_openai_and_anthropic() {
187        let o = Usage::from_openai(&json!({
188            "prompt_tokens": 100, "completion_tokens": 20,
189            "prompt_tokens_details": {"cached_tokens": 80}
190        }));
191        assert_eq!((o.prompt, o.completion, o.cache_read), (100, 20, 80));
192        let a = Usage::from_anthropic(&json!({
193            "input_tokens": 7, "output_tokens": 9,
194            "cache_read_input_tokens": 11, "cache_creation_input_tokens": 13
195        }));
196        assert_eq!(
197            (a.prompt, a.completion, a.cache_read, a.cache_creation),
198            (7, 9, 11, 13)
199        );
200    }
201
202    #[test]
203    fn parse_cursor() {
204        let c = Usage::from_cursor(&json!({
205            "inputTokens": 12660, "outputTokens": 93,
206            "cacheReadTokens": 23872, "cacheWriteTokens": 5
207        }));
208        assert_eq!(
209            (c.prompt, c.completion, c.cache_read, c.cache_creation),
210            (12660, 93, 23872, 5)
211        );
212    }
213}