1use std::collections::{BTreeSet, HashMap};
4use std::sync::Mutex;
5
6use crate::endpoints::ResolvedEndpoint;
7
8#[derive(Debug, Default, Clone)]
10pub struct EndpointUsage {
11 pub calls: u64,
13 pub input_tokens: u64,
15 pub output_tokens: u64,
17 pub cached_input_tokens: u64,
19 pub cache_creation_input_tokens: u64,
21 pub cost: f64,
23 pub models: BTreeSet<String>,
25}
26
27impl EndpointUsage {
28 const fn tokens(&self) -> u64 {
29 self.input_tokens + self.output_tokens
30 }
31}
32
33#[derive(Debug, Default, Clone)]
35pub struct UsageSnapshot {
36 pub endpoints: HashMap<String, EndpointUsage>,
38 pub total_cost: f64,
40 pub total_calls: u64,
42 pub total_tokens: u64,
44 pub total_input_tokens: u64,
46 pub total_output_tokens: u64,
48 pub total_cached_input_tokens: u64,
50 pub total_cache_creation_input_tokens: u64,
52 pub models: Vec<String>,
54}
55
56impl UsageSnapshot {
57 #[must_use]
59 pub fn from_endpoints(endpoints: &HashMap<String, EndpointUsage>) -> Self {
60 let total_cost = endpoints.values().map(|u| u.cost).sum();
61 let total_calls = endpoints.values().map(|u| u.calls).sum();
62 let total_tokens = endpoints.values().map(EndpointUsage::tokens).sum();
63 let total_input_tokens = endpoints.values().map(|u| u.input_tokens).sum();
64 let total_output_tokens = endpoints.values().map(|u| u.output_tokens).sum();
65 let total_cached_input_tokens = endpoints.values().map(|u| u.cached_input_tokens).sum();
66 let total_cache_creation_input_tokens = endpoints
67 .values()
68 .map(|u| u.cache_creation_input_tokens)
69 .sum();
70 let models: Vec<String> = endpoints
71 .values()
72 .flat_map(|u| u.models.iter().cloned())
73 .collect::<BTreeSet<_>>()
74 .into_iter()
75 .collect();
76 Self {
77 endpoints: endpoints.clone(),
78 total_cost,
79 total_calls,
80 total_tokens,
81 total_input_tokens,
82 total_output_tokens,
83 total_cached_input_tokens,
84 total_cache_creation_input_tokens,
85 models,
86 }
87 }
88}
89
90pub struct UsageTracker {
92 inner: Mutex<UsageInner>,
93}
94
95struct UsageInner {
96 per_endpoint: HashMap<String, EndpointUsage>,
98 global: UsageSnapshot,
100 per_test: Vec<(String, UsageSnapshot)>,
102}
103
104impl UsageTracker {
105 #[must_use]
107 pub fn new() -> Self {
108 Self {
109 inner: Mutex::new(UsageInner {
110 per_endpoint: HashMap::new(),
111 global: UsageSnapshot::default(),
112 per_test: Vec::new(),
113 }),
114 }
115 }
116
117 #[allow(clippy::significant_drop_tightening)]
124 pub fn record_llm_call(
125 &self,
126 endpoint_name: &str,
127 endpoint: &ResolvedEndpoint,
128 model: &str,
129 usage: &LlmUsage,
130 ) {
131 let cost = calculate_llm_cost(endpoint, usage.prompt_tokens, usage.completion_tokens);
132 let mut inner = self.inner.lock().unwrap();
133 let eu = inner
134 .per_endpoint
135 .entry(endpoint_name.to_owned())
136 .or_default();
137 eu.calls += 1;
138 eu.input_tokens += usage.prompt_tokens;
139 eu.output_tokens += usage.completion_tokens;
140 eu.cached_input_tokens += usage.cached_input_tokens;
141 eu.cache_creation_input_tokens += usage.cache_creation_input_tokens;
142 eu.cost += cost;
143 if !model.is_empty() {
144 eu.models.insert(model.to_owned());
145 }
146 }
147
148 #[allow(clippy::significant_drop_tightening)]
154 pub fn record_flat_call(&self, endpoint_name: &str, endpoint: &ResolvedEndpoint) {
155 let mut inner = self.inner.lock().unwrap();
156 let eu = inner
157 .per_endpoint
158 .entry(endpoint_name.to_owned())
159 .or_default();
160 eu.calls += 1;
161 eu.cost += endpoint.per_call_price;
162 }
163
164 #[must_use]
170 pub fn current_test_snapshot(&self) -> UsageSnapshot {
171 let inner = self.inner.lock().unwrap();
172 UsageSnapshot::from_endpoints(&inner.per_endpoint)
173 }
174
175 #[must_use]
181 pub fn global_snapshot(&self) -> UsageSnapshot {
182 let inner = self.inner.lock().unwrap();
183 inner.global.clone()
184 }
185
186 #[must_use]
192 pub fn per_test_snapshots(&self) -> Vec<(String, UsageSnapshot)> {
193 let inner = self.inner.lock().unwrap();
194 inner.per_test.clone()
195 }
196
197 pub fn reset_per_test(&self) {
203 let mut inner = self.inner.lock().unwrap();
204 inner.per_endpoint.clear();
205 }
206
207 pub fn commit_test(&self, test_name: &str) {
214 let mut inner = self.inner.lock().unwrap();
215 let snapshot = UsageSnapshot::from_endpoints(&inner.per_endpoint);
216 let ep_snapshot = inner.per_endpoint.clone();
218 for (ep_name, ep_usage) in &ep_snapshot {
219 let ge = inner.global.endpoints.entry(ep_name.clone()).or_default();
220 ge.calls += ep_usage.calls;
221 ge.input_tokens += ep_usage.input_tokens;
222 ge.output_tokens += ep_usage.output_tokens;
223 ge.cached_input_tokens += ep_usage.cached_input_tokens;
224 ge.cache_creation_input_tokens += ep_usage.cache_creation_input_tokens;
225 ge.cost += ep_usage.cost;
226 ge.models.extend(ep_usage.models.iter().cloned());
227 }
228 inner.global.total_cost += snapshot.total_cost;
229 inner.global.total_calls += snapshot.total_calls;
230 inner.global.total_tokens += snapshot.total_tokens;
231 inner.global.total_input_tokens += snapshot.total_input_tokens;
232 inner.global.total_output_tokens += snapshot.total_output_tokens;
233 inner.global.total_cached_input_tokens += snapshot.total_cached_input_tokens;
234 inner.global.total_cache_creation_input_tokens +=
235 snapshot.total_cache_creation_input_tokens;
236 inner.global.models = inner
237 .global
238 .endpoints
239 .values()
240 .flat_map(|u| u.models.iter().cloned())
241 .collect::<BTreeSet<_>>()
242 .into_iter()
243 .collect();
244 inner.per_test.push((test_name.to_owned(), snapshot));
245 }
246}
247
248impl Default for UsageTracker {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
256#[must_use]
257pub fn calculate_llm_cost(
258 endpoint: &ResolvedEndpoint,
259 input_tokens: u64,
260 output_tokens: u64,
261) -> f64 {
262 let input_cost = (input_tokens as f64 / 1_000_000.0) * endpoint.input_price_per_1m;
263 let output_cost = (output_tokens as f64 / 1_000_000.0) * endpoint.output_price_per_1m;
264 input_cost + output_cost
265}
266
267#[derive(Debug, Default, Clone, Copy)]
269pub struct LlmUsage {
270 pub prompt_tokens: u64,
272 pub completion_tokens: u64,
274 pub total_tokens: u64,
276 pub cached_input_tokens: u64,
278 pub cache_creation_input_tokens: u64,
280}
281
282#[derive(Debug, Clone)]
284pub struct LlmResponse {
285 pub content: String,
287 pub usage: LlmUsage,
289}
290
291#[must_use]
306pub fn extract_usage(value: &serde_json::Value) -> LlmUsage {
307 let usage = &value["usage"];
308 LlmUsage {
309 prompt_tokens: usage["prompt_tokens"].as_u64().unwrap_or(0),
310 completion_tokens: usage["completion_tokens"].as_u64().unwrap_or(0),
311 total_tokens: usage["total_tokens"].as_u64().unwrap_or(0),
312 cached_input_tokens: usage["prompt_tokens_details"]["cached_tokens"]
313 .as_u64()
314 .or_else(|| usage["cache_read_input_tokens"].as_u64())
315 .or_else(|| usage["prompt_cache_hit_tokens"].as_u64())
316 .unwrap_or(0),
317 cache_creation_input_tokens: usage["cache_creation_input_tokens"].as_u64().unwrap_or(0),
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use crate::costs::{calculate_llm_cost, LlmUsage, UsageTracker};
324 use crate::endpoints::ResolvedEndpoint;
325 use crate::scenario::EndpointType;
326
327 fn make_endpoint(
328 name: &str,
329 input_price: f64,
330 output_price: f64,
331 per_call: f64,
332 ) -> ResolvedEndpoint {
333 ResolvedEndpoint {
334 name: name.to_owned(),
335 endpoint_type: EndpointType::Llm,
336 url: String::new(),
337 model: None,
338 api_key: None,
339 headers: std::collections::HashMap::new(),
340 command: None,
341 args: vec![],
342 vision: false,
343 input_price_per_1m: input_price,
344 output_price_per_1m: output_price,
345 per_call_price: per_call,
346 max_attempts: 3,
347 fallbacks: vec![],
348 provider: crate::scenario::Provider::Openai,
349 deployment: None,
350 api_version: None,
351 auth: crate::scenario::AuthConfig::default(),
352 header_commands: std::collections::HashMap::new(),
353 aws: crate::scenario::AwsConfig::default(),
354 }
355 }
356
357 fn usage(prompt: u64, completion: u64, cached: u64) -> LlmUsage {
358 LlmUsage {
359 prompt_tokens: prompt,
360 completion_tokens: completion,
361 total_tokens: prompt + completion,
362 cached_input_tokens: cached,
363 cache_creation_input_tokens: 0,
364 }
365 }
366
367 #[test]
368 fn test_calculate_llm_cost() {
369 let ep = make_endpoint("test", 0.15, 0.60, 0.0);
370 let cost = calculate_llm_cost(&ep, 1_000_000, 500_000);
372 assert!((cost - 0.45).abs() < 0.001);
373 }
374
375 #[test]
376 fn test_calculate_zero_cost() {
377 let ep = make_endpoint("free", 0.0, 0.0, 0.0);
378 let cost = calculate_llm_cost(&ep, 1_000_000, 1_000_000);
379 assert!((cost - 0.0).abs() < f64::EPSILON);
380 }
381
382 #[test]
383 fn test_usage_tracker_record_llm() {
384 let tracker = UsageTracker::new();
385 let ep = make_endpoint("gpt4", 2.50, 10.0, 0.0);
386 tracker.record_llm_call("gpt4", &ep, "gpt-4o", &usage(1000, 500, 200));
387
388 let snap = tracker.current_test_snapshot();
389 assert_eq!(snap.total_calls, 1);
390 assert_eq!(snap.total_tokens, 1500);
391 assert_eq!(snap.total_input_tokens, 1000);
392 assert_eq!(snap.total_output_tokens, 500);
393 assert_eq!(snap.total_cached_input_tokens, 200);
394 assert_eq!(snap.models, vec!["gpt-4o".to_owned()]);
395 assert!(
396 snap.total_cost > 0.0,
397 "expected cost > 0, got {}",
398 snap.total_cost
399 );
400
401 let ep_usage = snap.endpoints.get("gpt4").unwrap();
402 assert_eq!(ep_usage.calls, 1);
403 assert_eq!(ep_usage.input_tokens, 1000);
404 assert_eq!(ep_usage.output_tokens, 500);
405 assert_eq!(ep_usage.cached_input_tokens, 200);
406 assert!(ep_usage.models.contains("gpt-4o"));
407 }
408
409 #[test]
410 fn test_usage_tracker_record_flat() {
411 let tracker = UsageTracker::new();
412 let ep = make_endpoint("agent", 0.0, 0.0, 0.01);
413 tracker.record_flat_call("agent", &ep);
414 tracker.record_flat_call("agent", &ep);
415
416 let snap = tracker.current_test_snapshot();
417 assert_eq!(snap.total_calls, 2);
418 assert!((snap.total_cost - 0.02).abs() < f64::EPSILON);
419 }
420
421 #[test]
422 fn test_usage_tracker_multiple_endpoints() {
423 let tracker = UsageTracker::new();
424 let fast = make_endpoint("fast", 0.15, 0.60, 0.0);
425 let slow = make_endpoint("slow", 2.50, 10.0, 0.0);
426
427 tracker.record_llm_call("fast", &fast, "fast-model", &usage(100, 50, 0));
428 tracker.record_llm_call("slow", &slow, "slow-model", &usage(200, 100, 0));
429
430 let snap = tracker.current_test_snapshot();
431 assert_eq!(snap.total_calls, 2);
432 assert_eq!(snap.endpoints.len(), 2);
433 assert_eq!(
434 snap.models,
435 vec!["fast-model".to_owned(), "slow-model".to_owned()]
436 );
437 }
438
439 #[test]
440 fn test_usage_tracker_reset_and_commit() {
441 let tracker = UsageTracker::new();
442 let ep = make_endpoint("test", 0.15, 0.60, 0.0);
443
444 tracker.record_llm_call("test", &ep, "m1", &usage(100, 50, 0));
445 tracker.commit_test("test1");
446 tracker.reset_per_test();
447
448 tracker.record_llm_call("test", &ep, "m2", &usage(200, 100, 0));
449 tracker.commit_test("test2");
450
451 let global = tracker.global_snapshot();
452 assert_eq!(global.total_calls, 2);
453 assert_eq!(global.total_tokens, 450);
454 assert_eq!(global.models, vec!["m1".to_owned(), "m2".to_owned()]);
455
456 let per_test = tracker.per_test_snapshots();
457 assert_eq!(per_test.len(), 2);
458 assert_eq!(per_test[0].0, "test1");
459 assert_eq!(per_test[1].0, "test2");
460 }
461}