Skip to main content

harn_vm/llm/
usage.rs

1//! Canonical LLM usage accounting and its public projections.
2//!
3//! Provider adapters own wire parsing, but once a call has produced token and
4//! cache counts every consumer must read this ledger. VM envelopes,
5//! transcripts, traces, metrics, and provider probes must not independently
6//! recompute cost or cache semantics.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::value::{VmDictExt, VmValue};
12
13use super::api::{LlmResult, ProviderAttempts};
14
15/// The normalized accounting facts for one completed provider call.
16///
17/// This is the sole owner of derived cost/cache facts. It deliberately keeps
18/// provider/model identity out of the public usage object: those remain route
19/// metadata on the enclosing result and transcript event.
20#[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    /// Project the stable Harn `usage` envelope. Retry accounting is supplied
106    /// by the observed-call boundary and stays nested under this one owner.
107    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    /// Mechanically add the canonical accounting fields to the flat provider
158    /// response event retained for CLI/backward compatibility.
159    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    /// Lower the ledger to canonical tracing metadata while keeping route
216    /// identity on the enclosing call.
217    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}