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, 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    /// Project the stable Harn `usage` envelope. Retry accounting is supplied
139    /// by the observed-call boundary and stays nested under this one owner.
140    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    /// Mechanically add the canonical accounting fields to the flat provider
192    /// response event retained for CLI/backward compatibility.
193    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    /// Lower the ledger to canonical tracing metadata while keeping route
255    /// identity on the enclosing call.
256    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}