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