1use serde::{Deserialize, Serialize};
10
11use crate::provider::Usage;
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
21pub struct UsageRecord {
22 pub turn: usize,
26 pub model: String,
31 #[serde(default)]
33 pub prompt_tokens: u64,
34 #[serde(default)]
36 pub completion_tokens: u64,
37 #[serde(default)]
40 pub total_tokens: u64,
41 #[serde(default)]
43 pub cached_tokens: Option<u64>,
44 #[serde(default)]
46 pub timestamp_ms: i64,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub cost_usd: Option<f64>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub served_model: Option<String>,
63}
64
65impl UsageRecord {
66 pub fn from_usage(turn: usize, model: &str, usage: &Usage, timestamp_ms: i64) -> UsageRecord {
69 UsageRecord {
70 turn,
71 model: model.to_string(),
72 prompt_tokens: usage.prompt_tokens,
73 completion_tokens: usage.completion_tokens,
74 total_tokens: usage.total_tokens,
75 cached_tokens: usage.prompt_tokens_details.map(|d| d.cached_tokens),
76 timestamp_ms,
77 cost_usd: None,
78 served_model: None,
79 }
80 }
81
82 pub fn priced(mut self, price: Option<crate::pricing::ModelPrice>) -> UsageRecord {
85 self.cost_usd = price.map(|p| p.cost_usd(self.prompt_tokens, self.completion_tokens));
86 self
87 }
88
89 pub fn with_served_model(mut self, served: Option<String>) -> UsageRecord {
93 self.served_model = served;
94 self
95 }
96
97 pub fn diverged(&self) -> bool {
101 self.served_model
102 .as_deref()
103 .is_some_and(|served| served != self.model)
104 }
105}
106
107pub fn to_jsonl(records: &[UsageRecord]) -> crate::Result<String> {
111 let mut out = String::new();
112 for r in records {
113 out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
114 out.push('\n');
115 }
116 Ok(out)
117}
118
119pub fn from_jsonl(text: &str) -> crate::Result<Vec<UsageRecord>> {
125 let mut out = Vec::new();
126 for line in text.lines() {
127 let line = line.trim();
128 if line.is_empty() {
129 continue;
130 }
131 out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
132 }
133 Ok(out)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::provider::PromptTokensDetails;
140
141 #[test]
142 fn from_usage_carries_every_field() {
143 let usage = Usage {
144 prompt_tokens: 100,
145 completion_tokens: 20,
146 total_tokens: 120,
147 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 40 }),
148 };
149 let r = UsageRecord::from_usage(2, "anthropic/claude-opus-4-8", &usage, 1_700_000_000_000);
150 assert_eq!(r.turn, 2);
151 assert_eq!(r.model, "anthropic/claude-opus-4-8");
152 assert_eq!(r.prompt_tokens, 100);
153 assert_eq!(r.completion_tokens, 20);
154 assert_eq!(r.total_tokens, 120);
155 assert_eq!(r.cached_tokens, Some(40));
156 assert_eq!(r.timestamp_ms, 1_700_000_000_000);
157 }
158
159 #[test]
160 fn priced_fills_cost_only_when_a_price_resolves() {
161 let usage = Usage {
162 prompt_tokens: 1_000_000,
163 completion_tokens: 0,
164 total_tokens: 1_000_000,
165 prompt_tokens_details: None,
166 };
167 let record = UsageRecord::from_usage(0, "anthropic/claude-opus-4-8", &usage, 0);
168 assert_eq!(record.cost_usd, None, "unpriced until `priced` is called");
169 let priced = record
170 .clone()
171 .priced(crate::pricing::built_in(&record.model));
172 assert_eq!(
173 priced.cost_usd,
174 Some(crate::pricing_ref::REF_INPUT_PER_MTOK)
175 );
176 assert_eq!(record.priced(None).cost_usd, None);
177 }
178
179 #[test]
180 fn from_usage_with_no_cache_details_is_none() {
181 let usage = Usage {
182 prompt_tokens: 10,
183 completion_tokens: 5,
184 total_tokens: 15,
185 prompt_tokens_details: None,
186 };
187 let r = UsageRecord::from_usage(0, "m", &usage, 0);
188 assert_eq!(r.cached_tokens, None);
189 }
190
191 #[test]
192 fn jsonl_round_trip_is_lossless() {
193 let records = vec![
194 UsageRecord {
195 turn: 0,
196 model: "anthropic/claude-opus-4-8".to_string(),
197 prompt_tokens: 1000,
198 completion_tokens: 50,
199 total_tokens: 1050,
200 cached_tokens: Some(200),
201 timestamp_ms: 1_700_000_000_000,
202 cost_usd: Some(0.018_75),
203 served_model: Some("anthropic/claude-opus-4-8-20260101".to_string()),
204 },
205 UsageRecord {
206 turn: 1,
207 model: "anthropic/claude-haiku-4-5".to_string(),
208 prompt_tokens: 2000,
209 completion_tokens: 75,
210 total_tokens: 2075,
211 cached_tokens: None,
212 timestamp_ms: 1_700_000_005_000,
213 cost_usd: None,
214 served_model: None,
215 },
216 ];
217 let jsonl = to_jsonl(&records).unwrap();
218 let round_tripped = from_jsonl(&jsonl).unwrap();
219 assert_eq!(records, round_tripped);
220 }
221
222 #[test]
225 fn served_model_records_divergence_and_never_infers_agreement() {
226 let usage = Usage::default();
227 let asked = UsageRecord::from_usage(0, "vendor/floating", &usage, 0);
228 assert_eq!(asked.served_model, None);
229 assert!(!asked.diverged(), "an unknown is not a divergence");
230 let served = asked
231 .clone()
232 .with_served_model(Some("vendor/floating-20260101".to_string()));
233 assert!(served.diverged());
234 let same = UsageRecord::from_usage(0, "vendor/m", &usage, 0)
235 .with_served_model(Some("vendor/m".to_string()));
236 assert!(!same.diverged());
237 let old = r#"{"turn":0,"model":"m","prompt_tokens":1}"#;
239 assert_eq!(from_jsonl(old).unwrap()[0].served_model, None);
240 }
241
242 #[test]
243 fn empty_records_round_trip_to_empty() {
244 assert_eq!(to_jsonl(&[]).unwrap(), "");
245 assert_eq!(from_jsonl("").unwrap(), Vec::<UsageRecord>::new());
246 }
247
248 #[test]
249 fn from_jsonl_skips_blank_lines() {
250 let text = "\n\n";
251 assert_eq!(from_jsonl(text).unwrap(), Vec::<UsageRecord>::new());
252 }
253}