1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::value::{VmDictExt, VmValue};
12
13use super::api::{LlmResult, ProviderAttempts};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct LlmUsage {
22 pub input_tokens: i64,
23 pub output_tokens: i64,
24 pub cost_usd: Option<f64>,
25 pub cache_read_tokens: i64,
26 pub cache_write_tokens: i64,
27 pub cache_supported: bool,
28 pub cache_hit_ratio: Option<f64>,
29 pub cache_savings_usd: f64,
30 pub cache_hit: bool,
31 pub served_fast: bool,
32}
33
34impl LlmUsage {
35 pub(crate) fn from_result(result: &LlmResult) -> Self {
36 let cost_usd = super::managed_supply::authoritative_cost_usd(result).or_else(|| {
37 super::cost::pricing_detail_for_tier(
38 &result.provider,
39 &result.model,
40 result.served_fast,
41 result.input_tokens,
42 )
43 .map(|detail| {
44 super::cost::project_call_cost(
45 &detail,
46 result.input_tokens,
47 result.output_tokens,
48 result.cache_read_tokens,
49 result.cache_write_tokens,
50 )
51 })
52 });
53 let cache_hit_ratio = result.cache_supported.then(|| {
54 super::cost::cache_hit_ratio(
55 result.input_tokens,
56 result.cache_read_tokens,
57 result.cache_write_tokens,
58 )
59 });
60 Self {
61 input_tokens: result.input_tokens,
62 output_tokens: result.output_tokens,
63 cost_usd,
64 cache_read_tokens: result.cache_read_tokens,
65 cache_write_tokens: result.cache_write_tokens,
66 cache_supported: result.cache_supported,
67 cache_hit_ratio,
68 cache_savings_usd: super::cost::cache_savings_usd_for_provider(
69 &result.provider,
70 &result.model,
71 result.input_tokens,
72 result.cache_read_tokens,
73 result.cache_write_tokens,
74 ),
75 cache_hit: result.cache_read_tokens > 0,
76 served_fast: result.served_fast,
77 }
78 }
79
80 fn from_probe_counts(
81 provider: &str,
82 model: &str,
83 input_tokens: i64,
84 output_tokens: i64,
85 ) -> Self {
86 Self {
87 input_tokens,
88 output_tokens,
89 cost_usd: super::cost::pricing_aware_call_cost(
90 provider,
91 model,
92 input_tokens,
93 output_tokens,
94 ),
95 cache_read_tokens: 0,
96 cache_write_tokens: 0,
97 cache_supported: false,
98 cache_hit_ratio: None,
99 cache_savings_usd: 0.0,
100 cache_hit: false,
101 served_fast: false,
102 }
103 }
104
105 pub(crate) fn to_vm_dict(&self, attempts: &ProviderAttempts) -> crate::value::DictMap {
108 let mut usage = crate::value::DictMap::new();
109 usage.insert(
110 crate::value::intern_key("input_tokens"),
111 VmValue::Int(self.input_tokens),
112 );
113 usage.insert(
114 crate::value::intern_key("output_tokens"),
115 VmValue::Int(self.output_tokens),
116 );
117 usage.insert(
118 crate::value::intern_key("cost_usd"),
119 self.cost_usd.map_or(VmValue::Nil, VmValue::Float),
120 );
121 usage.insert(
122 crate::value::intern_key("cache_read_tokens"),
123 VmValue::Int(self.cache_read_tokens),
124 );
125 usage.insert(
126 crate::value::intern_key("cache_write_tokens"),
127 VmValue::Int(self.cache_write_tokens),
128 );
129 usage.insert(
130 crate::value::intern_key("cache_supported"),
131 VmValue::Bool(self.cache_supported),
132 );
133 usage.insert(
134 crate::value::intern_key("cache_hit_ratio"),
135 self.cache_hit_ratio.map_or(VmValue::Nil, VmValue::Float),
136 );
137 if self.cache_supported {
138 usage.insert(crate::value::intern_key("cache_visibility"), VmValue::Nil);
139 } else {
140 usage.put_str("cache_visibility", "unsupported");
141 }
142 usage.insert(
143 crate::value::intern_key("cache_savings_usd"),
144 VmValue::Float(self.cache_savings_usd),
145 );
146 usage.insert(
147 crate::value::intern_key("provider_attempts"),
148 VmValue::dict(provider_attempts_vm_dict(attempts)),
149 );
150 usage.insert(
151 crate::value::intern_key("served_fast"),
152 VmValue::Bool(self.served_fast),
153 );
154 usage
155 }
156
157 pub(crate) fn project_onto_event(&self, event: &mut serde_json::Value) {
160 let fields = event
161 .as_object_mut()
162 .expect("usage projection target must be a JSON object");
163 fields.insert("input_tokens".to_string(), self.input_tokens.into());
164 fields.insert("output_tokens".to_string(), self.output_tokens.into());
165 fields.insert(
166 "cost_usd".to_string(),
167 self.cost_usd.map_or(Value::Null, serde_json::Value::from),
168 );
169 fields.insert(
170 "cache_read_tokens".to_string(),
171 self.cache_read_tokens.into(),
172 );
173 fields.insert(
174 "cache_write_tokens".to_string(),
175 self.cache_write_tokens.into(),
176 );
177 fields.insert("cache_supported".to_string(), self.cache_supported.into());
178 fields.insert(
179 "cache_hit_ratio".to_string(),
180 self.cache_hit_ratio
181 .map_or(Value::Null, serde_json::Value::from),
182 );
183 fields.insert(
184 "cache_visibility".to_string(),
185 if self.cache_supported {
186 Value::Null
187 } else {
188 Value::String("unsupported".to_string())
189 },
190 );
191 fields.insert(
192 "cache_savings_usd".to_string(),
193 self.cache_savings_usd.into(),
194 );
195 fields.insert("cache_hit".to_string(), self.cache_hit.into());
196 fields.insert("served_fast".to_string(), self.served_fast.into());
197 }
198
199 pub(crate) fn empty_vm_dict() -> crate::value::DictMap {
200 Self {
201 input_tokens: 0,
202 output_tokens: 0,
203 cost_usd: None,
204 cache_read_tokens: 0,
205 cache_write_tokens: 0,
206 cache_supported: true,
207 cache_hit_ratio: Some(0.0),
208 cache_savings_usd: 0.0,
209 cache_hit: false,
210 served_fast: false,
211 }
212 .to_vm_dict(&ProviderAttempts::default())
213 }
214
215 pub(crate) fn metadata_pairs(
218 &self,
219 provider: &str,
220 model: &str,
221 ) -> Vec<(&'static str, serde_json::Value)> {
222 use crate::tracing::meta;
223
224 let mut pairs = vec![
225 (meta::MODEL, serde_json::json!(model)),
226 (meta::PROVIDER, serde_json::json!(provider)),
227 (meta::INPUT_TOKENS, serde_json::json!(self.input_tokens)),
228 (meta::OUTPUT_TOKENS, serde_json::json!(self.output_tokens)),
229 (
230 meta::CACHE_READ_TOKENS,
231 serde_json::json!(self.cache_read_tokens),
232 ),
233 (
234 meta::CACHE_WRITE_TOKENS,
235 serde_json::json!(self.cache_write_tokens),
236 ),
237 ];
238 if let Some(cost) = self.cost_usd {
239 pairs.push((meta::COST_USD, serde_json::json!(cost)));
240 }
241 pairs
242 }
243}
244
245fn provider_attempts_vm_dict(attempts: &ProviderAttempts) -> crate::value::DictMap {
246 let mut fields = crate::value::DictMap::new();
247 fields.insert(
248 crate::value::intern_key("total"),
249 VmValue::Int(i64::from(attempts.total)),
250 );
251 fields.insert(
252 crate::value::intern_key("retries"),
253 VmValue::Int(i64::from(attempts.retries())),
254 );
255 fields.insert(
256 crate::value::intern_key("rate_limited"),
257 VmValue::Int(i64::from(attempts.rate_limited)),
258 );
259 fields.insert(
260 crate::value::intern_key("empty_completion"),
261 VmValue::Int(i64::from(attempts.empty_completion)),
262 );
263 fields.insert(
264 crate::value::intern_key("other"),
265 VmValue::Int(i64::from(attempts.other)),
266 );
267 fields
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271pub struct ToolProbeUsage {
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub input_tokens: Option<i64>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub output_tokens: Option<i64>,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub cost_usd: Option<f64>,
278}
279
280impl ToolProbeUsage {
281 fn from_totals(provider: &str, model: &str, totals: UsageTotals) -> Self {
282 if let Some((input_tokens, output_tokens)) = totals.input_tokens.zip(totals.output_tokens) {
283 let usage = LlmUsage::from_probe_counts(provider, model, input_tokens, output_tokens);
284 return Self {
285 input_tokens: Some(usage.input_tokens),
286 output_tokens: Some(usage.output_tokens),
287 cost_usd: usage.cost_usd,
288 };
289 }
290 Self {
291 input_tokens: totals.input_tokens,
292 output_tokens: totals.output_tokens,
293 cost_usd: None,
294 }
295 }
296}
297
298#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
299struct UsageTotals {
300 input_tokens: Option<i64>,
301 output_tokens: Option<i64>,
302}
303
304impl UsageTotals {
305 fn has_any(self) -> bool {
306 self.input_tokens.is_some() || self.output_tokens.is_some()
307 }
308
309 fn add_input(&mut self, value: i64) {
310 self.input_tokens = Some(self.input_tokens.unwrap_or(0).saturating_add(value.max(0)));
311 }
312
313 fn add_output(&mut self, value: i64) {
314 self.output_tokens = Some(self.output_tokens.unwrap_or(0).saturating_add(value.max(0)));
315 }
316}
317
318pub(crate) fn extract_probe_usage(
319 provider: &str,
320 model: &str,
321 response: &Value,
322) -> Option<ToolProbeUsage> {
323 let totals = usage_totals_from_response(response)?;
324 Some(ToolProbeUsage::from_totals(provider, model, totals))
325}
326
327fn usage_totals_from_response(response: &Value) -> Option<UsageTotals> {
328 let root_totals = usage_totals_from_envelope(response);
329 if root_totals.has_any() {
330 return Some(root_totals);
331 }
332 let frame_totals = last_stream_frame_usage(response);
333 frame_totals.has_any().then_some(frame_totals)
334}
335
336fn last_stream_frame_usage(response: &Value) -> UsageTotals {
337 let mut final_totals = UsageTotals::default();
338 let Some(frames) = response.get("frames").and_then(Value::as_array) else {
339 return final_totals;
340 };
341 for frame in frames {
342 let frame_totals = usage_totals_from_envelope(frame);
343 if frame_totals.has_any() {
344 final_totals = frame_totals;
345 }
346 }
347 final_totals
348}
349
350fn usage_totals_from_envelope(envelope: &Value) -> UsageTotals {
351 let mut totals = UsageTotals::default();
352 accumulate_usage_object(envelope.get("usage"), &mut totals);
353 accumulate_usage_object(envelope.pointer("/message/usage"), &mut totals);
354 accumulate_usage_object(envelope.get("usageMetadata"), &mut totals);
355 accumulate_usage_object(envelope.pointer("/message/usageMetadata"), &mut totals);
356 totals
357}
358
359fn accumulate_usage_object(usage: Option<&Value>, totals: &mut UsageTotals) {
360 let Some(usage) = usage else {
361 return;
362 };
363 if let Some(value) = first_i64_field(
364 usage,
365 &[
366 "input_tokens",
367 "prompt_tokens",
368 "promptTokenCount",
369 "prompt_token_count",
370 "inputTokens",
371 ],
372 ) {
373 totals.add_input(value);
374 }
375
376 let output_tokens = first_i64_field(
377 usage,
378 &[
379 "output_tokens",
380 "completion_tokens",
381 "candidatesTokenCount",
382 "completion_token_count",
383 "outputTokenCount",
384 "outputTokens",
385 ],
386 );
387 let thoughts_tokens = first_i64_field(usage, &["thoughtsTokenCount", "thought_tokens"]);
388 match (output_tokens, thoughts_tokens) {
389 (Some(output), Some(thoughts)) => totals.add_output(output.saturating_add(thoughts)),
390 (Some(output), None) => totals.add_output(output),
391 (None, Some(thoughts)) => totals.add_output(thoughts),
392 (None, None) => {}
393 }
394}
395
396fn first_i64_field(value: &Value, names: &[&str]) -> Option<i64> {
397 names
398 .iter()
399 .find_map(|name| value.get(*name).and_then(Value::as_i64))
400}
401
402#[cfg(test)]
403mod tests {
404 use serde_json::json;
405
406 use super::extract_probe_usage;
407 use crate::llm::api::{LlmResult, ProviderAttempts, ProviderTelemetry};
408 use crate::value::VmValue;
409
410 fn accounted_result() -> LlmResult {
411 LlmResult {
412 text: "ok".to_string(),
413 tool_calls: Vec::new(),
414 text_projection: None,
415 raw_tool_calls: Vec::new(),
416 input_tokens: 1_000,
417 output_tokens: 100,
418 cache_read_tokens: 800,
419 cache_write_tokens: 25,
420 cache_supported: true,
421 model: "claude-sonnet-4-20250514".to_string(),
422 provider: "anthropic".to_string(),
423 thinking: None,
424 thinking_summary: None,
425 stop_reason: Some("end_turn".to_string()),
426 served_fast: false,
427 blocks: Vec::new(),
428 logprobs: Vec::new(),
429 telemetry: ProviderTelemetry::default(),
430 attempts: ProviderAttempts {
431 total: 3,
432 rate_limited: 1,
433 empty_completion: 1,
434 other: 0,
435 },
436 }
437 }
438
439 #[test]
440 fn one_ledger_projects_matching_vm_event_and_trace_accounting() {
441 let result = accounted_result();
442 let usage = result.usage();
443 let vm_usage =
444 crate::llm::vm_value_to_json(&VmValue::Dict(usage.to_vm_dict(&result.attempts).into()));
445 let mut event = json!({});
446 usage.project_onto_event(&mut event);
447 let trace = usage
448 .metadata_pairs(&result.provider, &result.model)
449 .into_iter()
450 .collect::<std::collections::BTreeMap<_, _>>();
451
452 for field in [
453 "input_tokens",
454 "output_tokens",
455 "cost_usd",
456 "cache_read_tokens",
457 "cache_write_tokens",
458 "cache_hit_ratio",
459 "cache_savings_usd",
460 "served_fast",
461 ] {
462 assert_eq!(
463 vm_usage.get(field),
464 event.get(field),
465 "{field} drifted between canonical projections"
466 );
467 }
468 assert_eq!(
469 trace[crate::tracing::meta::INPUT_TOKENS],
470 event["input_tokens"]
471 );
472 assert_eq!(
473 trace[crate::tracing::meta::OUTPUT_TOKENS],
474 event["output_tokens"]
475 );
476 assert_eq!(trace[crate::tracing::meta::COST_USD], event["cost_usd"]);
477 assert_eq!(vm_usage["provider_attempts"]["retries"], json!(2));
478 }
479
480 #[test]
481 fn public_usage_projections_do_not_recompute_accounting() {
482 let projection_sources = [
483 (
484 "transcript",
485 include_str!("agent_observe/transcript_observability.rs"),
486 ),
487 (
488 "structured envelope",
489 include_str!("structured_envelope.rs"),
490 ),
491 ("trace", include_str!("trace.rs")),
492 ("agent result", include_str!("agent_config.rs")),
493 ];
494 for (name, source) in projection_sources {
495 for forbidden in [
496 "priced_cost_usd(",
497 "cache_hit_ratio(",
498 "cache_savings_usd_for_provider(",
499 "struct LlmCallUsage",
500 ] {
501 assert!(
502 !source.contains(forbidden),
503 "{name} rebuilt canonical usage via {forbidden}"
504 );
505 }
506 }
507 }
508
509 #[test]
510 fn extracts_openai_responses_usage() {
511 let response = json!({
512 "usage": {
513 "input_tokens": 11,
514 "output_tokens": 7
515 }
516 });
517
518 let usage = extract_probe_usage("unknown", "unknown", &response).expect("usage");
519
520 assert_eq!(usage.input_tokens, Some(11));
521 assert_eq!(usage.output_tokens, Some(7));
522 assert_eq!(usage.cost_usd, None);
523 }
524
525 #[test]
526 fn extracts_gemini_usage_metadata_with_thoughts() {
527 let response = json!({
528 "usageMetadata": {
529 "promptTokenCount": 3,
530 "candidatesTokenCount": 4,
531 "thoughtsTokenCount": 9
532 }
533 });
534
535 let usage = extract_probe_usage("gemini", "gemini-2.5-pro", &response).expect("usage");
536
537 assert_eq!(usage.input_tokens, Some(3));
538 assert_eq!(usage.output_tokens, Some(13));
539 }
540
541 #[test]
542 fn extracts_vertex_usage_metadata_from_message_wrapper() {
543 let response = json!({
544 "message": {
545 "usageMetadata": {
546 "promptTokenCount": 5,
547 "candidatesTokenCount": 8
548 }
549 }
550 });
551
552 let usage = extract_probe_usage("vertex", "gemini-2.5-flash", &response).expect("usage");
553
554 assert_eq!(usage.input_tokens, Some(5));
555 assert_eq!(usage.output_tokens, Some(8));
556 }
557
558 #[test]
559 fn extracts_bedrock_usage_tokens() {
560 let response = json!({
561 "usage": {
562 "inputTokens": 17,
563 "outputTokens": 23
564 }
565 });
566
567 let usage = extract_probe_usage("bedrock", "claude-sonnet-5", &response).expect("usage");
568
569 assert_eq!(usage.input_tokens, Some(17));
570 assert_eq!(usage.output_tokens, Some(23));
571 }
572
573 #[test]
574 fn uses_final_stream_usage_without_double_counting_prior_frames() {
575 let response = json!({
576 "frames": [
577 {
578 "usage": {
579 "prompt_tokens": 1,
580 "completion_tokens": 1
581 }
582 },
583 {
584 "usage": {
585 "prompt_tokens": 10,
586 "completion_tokens": 2
587 }
588 }
589 ]
590 });
591
592 let usage = extract_probe_usage("unknown", "unknown", &response).expect("usage");
593
594 assert_eq!(usage.input_tokens, Some(10));
595 assert_eq!(usage.output_tokens, Some(2));
596 }
597
598 #[test]
599 fn root_usage_dominates_copied_stream_frames() {
600 let response = json!({
601 "usage": {
602 "prompt_tokens": 10,
603 "completion_tokens": 2
604 },
605 "frames": [
606 {
607 "usage": {
608 "prompt_tokens": 10,
609 "completion_tokens": 2
610 }
611 }
612 ]
613 });
614
615 let usage = extract_probe_usage("unknown", "unknown", &response).expect("usage");
616
617 assert_eq!(usage.input_tokens, Some(10));
618 assert_eq!(usage.output_tokens, Some(2));
619 }
620}