1use serde::{Deserialize, Serialize};
12
13use crate::core::savings_ledger::{RoiReport, roi_report};
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct Usage {
20 pub schema_version: u32,
22 pub period: String,
24 pub created_at: String,
26 pub agent_id: String,
28
29 pub metered_events: usize,
32 pub net_saved_tokens: u64,
34 pub saved_usd: f64,
36
37 pub last_entry_hash: String,
40 pub chain_valid: bool,
42 pub signed: bool,
44}
45
46impl Usage {
47 pub const SCHEMA_VERSION: u32 = 1;
49
50 #[must_use]
52 pub fn from_roi(roi: &RoiReport) -> Self {
53 Self {
54 schema_version: Self::SCHEMA_VERSION,
55 period: roi.period.clone(),
56 created_at: roi.created_at.clone(),
57 agent_id: roi.agent_id.clone(),
58 metered_events: roi.total_events,
59 net_saved_tokens: roi.net_saved_tokens,
60 saved_usd: roi.saved_usd,
61 last_entry_hash: roi.last_entry_hash.clone(),
62 chain_valid: roi.chain_valid,
63 signed: roi.signed,
64 }
65 }
66
67 #[must_use]
71 pub fn is_billable(&self) -> bool {
72 self.signed && self.chain_valid
73 }
74
75 #[must_use]
77 pub fn headline(&self) -> String {
78 format!(
79 "Usage[{}]: {} events, {} net tokens, ${:.4} ({}, {})",
80 self.period,
81 self.metered_events,
82 self.net_saved_tokens,
83 self.saved_usd,
84 if self.chain_valid {
85 "chain valid"
86 } else {
87 "chain BROKEN"
88 },
89 if self.is_billable() {
90 "billable"
91 } else {
92 "not billable"
93 },
94 )
95 }
96}
97
98#[must_use]
102pub fn metered_usage(agent_id: &str) -> Usage {
103 Usage::from_roi(&roi_report(agent_id))
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::core::savings_ledger::signed_batch::{BatchTotals, SignedSavingsBatchV1};
110
111 fn roi(events: usize, net: u64, usd: f64, signed: bool, chain_valid: bool) -> RoiReport {
112 let batch = SignedSavingsBatchV1 {
113 schema_version: 1,
114 kind: "lean-ctx.savings-batch".to_string(),
115 created_at: "2026-01-01T00:00:00Z".to_string(),
116 lean_ctx_version: "test".to_string(),
117 agent_id: "agent-1".to_string(),
118 period: "all".to_string(),
119 first_entry_hash: "genesis".to_string(),
120 last_entry_hash: "deadbeef".to_string(),
121 chain_valid,
122 totals: BatchTotals {
123 total_events: events,
124 saved_tokens: net + 10,
125 net_saved_tokens: net,
126 saved_usd: usd,
127 bounce_tokens: 10,
128 bounce_events: 1,
129 tokenizers: vec!["o200k_base".to_string()],
130 by_model: vec![("gpt".to_string(), net, usd)],
131 by_tool: vec![("ctx_read".to_string(), net)],
132 },
133 signer_public_key: signed.then(|| "pubkey".to_string()),
134 signature: signed.then(|| "sig".to_string()),
135 };
136 RoiReport::from_signed_batch(&batch)
137 }
138
139 #[test]
140 fn usage_mirrors_roi_aggregates() {
141 let u = Usage::from_roi(&roi(7, 7000, 0.14, true, true));
142 assert_eq!(u.metered_events, 7);
143 assert_eq!(u.net_saved_tokens, 7000);
144 assert!((u.saved_usd - 0.14).abs() < 1e-9);
145 assert_eq!(u.last_entry_hash, "deadbeef");
146 assert_eq!(u.schema_version, Usage::SCHEMA_VERSION);
147 }
148
149 #[test]
150 fn only_signed_intact_chains_are_billable() {
151 assert!(Usage::from_roi(&roi(1, 1, 0.0, true, true)).is_billable());
152 assert!(!Usage::from_roi(&roi(1, 1, 0.0, false, true)).is_billable());
153 assert!(!Usage::from_roi(&roi(1, 1, 0.0, true, false)).is_billable());
154 }
155
156 #[test]
157 fn usage_is_privacy_preserving() {
158 let json = serde_json::to_string(&Usage::from_roi(&roi(2, 100, 0.01, true, true))).unwrap();
159 for forbidden in ["path", "prompt", "content", "cwd", "\"file\""] {
160 assert!(!json.contains(forbidden), "usage leaked '{forbidden}'");
161 }
162 }
163}