1use layover_core::cost::CostSource;
24use serde::Deserialize;
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct Reported {
29 pub usd: f64,
31 pub source: CostSource,
33 pub input_tokens: u64,
35 pub output_tokens: u64,
37}
38
39impl Reported {
40 #[must_use]
45 pub const fn unreported() -> Self {
46 Self {
47 usd: 0.0,
48 source: CostSource::Unreported,
49 input_tokens: 0,
50 output_tokens: 0,
51 }
52 }
53}
54
55#[derive(Debug, Deserialize)]
60struct Line {
61 #[serde(alias = "total_cost_usd", alias = "cost_usd", alias = "costUSD")]
62 cost: Option<f64>,
63 usage: Option<Usage>,
64}
65
66#[derive(Debug, Deserialize)]
67struct Usage {
68 #[serde(alias = "input_tokens", alias = "prompt_tokens")]
69 input: Option<u64>,
70 #[serde(alias = "output_tokens", alias = "completion_tokens")]
71 output: Option<u64>,
72}
73
74const TOKENS_PER_DOLLAR: f64 = 1_000_000.0;
80
81#[must_use]
86pub fn from_transcript(text: &str) -> Reported {
87 let mut best: Option<Reported> = None;
88
89 for line in text.lines() {
90 let line = line.trim();
91 if !line.starts_with('{') {
92 continue;
93 }
94
95 let Ok(parsed) = serde_json::from_str::<Line>(line) else {
96 continue;
97 };
98
99 let input = parsed.usage.as_ref().and_then(|u| u.input).unwrap_or(0);
100 let output = parsed.usage.as_ref().and_then(|u| u.output).unwrap_or(0);
101
102 let Some(usd) = parsed.cost else {
103 if input + output > 0 {
106 best = Some(Reported {
107 usd: 0.0,
108 source: CostSource::Unreported,
109 input_tokens: input,
110 output_tokens: output,
111 });
112 }
113 continue;
114 };
115
116 best = Some(judge(usd, input, output));
117 }
118
119 best.unwrap_or_else(Reported::unreported)
120}
121
122fn judge(usd: f64, input: u64, output: u64) -> Reported {
124 let tokens = input + output;
125
126 if !usd.is_finite() || usd < 0.0 {
129 return Reported {
130 usd: 0.0,
131 source: CostSource::Unreported,
132 input_tokens: input,
133 output_tokens: output,
134 };
135 }
136
137 if usd == 0.0 && tokens > 0 {
140 return Reported {
141 usd: 0.0,
142 source: CostSource::Unreported,
143 input_tokens: input,
144 output_tokens: output,
145 };
146 }
147
148 if tokens > 0 {
152 #[expect(
154 clippy::cast_precision_loss,
155 reason = "token counts never approach 2^53"
156 )]
157 let implied = tokens as f64 / TOKENS_PER_DOLLAR;
158 if usd > 0.0 && usd * 10.0 < implied {
159 return Reported {
160 usd: 0.0,
161 source: CostSource::Unreported,
162 input_tokens: input,
163 output_tokens: output,
164 };
165 }
166 }
167
168 Reported {
169 usd,
170 source: CostSource::Reported,
171 input_tokens: input,
172 output_tokens: output,
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn a_plain_total_is_read() {
182 let got = from_transcript(r#"{"total_cost_usd": 1.25, "usage": {"input_tokens": 900000}}"#);
183
184 assert!((got.usd - 1.25).abs() < f64::EPSILON);
185 assert_eq!(got.source, CostSource::Reported);
186 assert_eq!(got.input_tokens, 900_000);
187 }
188
189 #[test]
190 fn the_last_total_wins_because_runners_emit_running_ones() {
191 let text = "\
192{\"total_cost_usd\": 0.10, \"usage\": {\"input_tokens\": 100000}}
193the agent says something
194{\"total_cost_usd\": 0.90, \"usage\": {\"input_tokens\": 800000}}";
195
196 assert!((from_transcript(text).usd - 0.90).abs() < f64::EPSILON);
197 }
198
199 #[test]
200 fn prose_between_the_json_is_ignored() {
201 let text = "Thinking about it.\nI will run the suite.\n{\"cost_usd\": 0.4, \"usage\": {\"input_tokens\": 350000}}\nDone.";
202 assert!((from_transcript(text).usd - 0.4).abs() < f64::EPSILON);
203 }
204
205 #[test]
206 fn a_transcript_with_no_numbers_is_unreported_not_free() {
207 let got = from_transcript("I looked at the file and it seemed fine.");
209
210 assert_eq!(got.source, CostSource::Unreported);
211 assert!(got.usd.abs() < f64::EPSILON);
212 }
213
214 #[test]
215 fn zero_dollars_with_real_tokens_is_silence() {
216 let got = from_transcript(r#"{"total_cost_usd": 0.0, "usage": {"input_tokens": 50000}}"#);
217
218 assert_eq!(got.source, CostSource::Unreported);
219 assert_eq!(
220 got.input_tokens, 50_000,
221 "the tokens are still worth keeping"
222 );
223 }
224
225 #[test]
226 fn a_negative_cost_cannot_credit_fuel_back() {
227 let got = from_transcript(r#"{"total_cost_usd": -5.0, "usage": {"output_tokens": 1000}}"#);
228
229 assert!(got.usd.abs() < f64::EPSILON);
230 assert_eq!(got.source, CostSource::Unreported);
231 }
232
233 #[test]
234 fn a_cost_wildly_below_what_the_tokens_imply_is_not_believed() {
235 let got =
238 from_transcript(r#"{"total_cost_usd": 0.01, "usage": {"input_tokens": 4000000}}"#);
239
240 assert_eq!(
241 got.source,
242 CostSource::Unreported,
243 "four million tokens does not cost a cent"
244 );
245 }
246
247 #[test]
248 fn a_cost_merely_cheaper_than_the_yardstick_is_still_believed() {
249 let got = from_transcript(r#"{"total_cost_usd": 0.5, "usage": {"input_tokens": 1000000}}"#);
252
253 assert_eq!(got.source, CostSource::Reported);
254 assert!((got.usd - 0.5).abs() < f64::EPSILON);
255 }
256
257 #[test]
258 fn tokens_without_a_cost_still_record_that_work_happened() {
259 let got = from_transcript(r#"{"usage": {"input_tokens": 1200, "output_tokens": 300}}"#);
260
261 assert_eq!(got.source, CostSource::Unreported);
262 assert_eq!(got.input_tokens, 1200);
263 assert_eq!(got.output_tokens, 300);
264 }
265
266 #[test]
267 fn malformed_json_does_not_stop_the_readable_lines_being_read() {
268 let text = "{\"total_cost_usd\": oops}\n{\"total_cost_usd\": 2.0, \"usage\": {\"input_tokens\": 1900000}}";
271 assert!((from_transcript(text).usd - 2.0).abs() < f64::EPSILON);
272 }
273
274 #[test]
275 fn an_alternative_field_name_is_accepted() {
276 for text in [
278 r#"{"total_cost_usd": 3.0, "usage": {"input_tokens": 2900000}}"#,
279 r#"{"cost_usd": 3.0, "usage": {"prompt_tokens": 2900000}}"#,
280 r#"{"costUSD": 3.0, "usage": {"input": 2900000}}"#,
281 ] {
282 assert!(
283 (from_transcript(text).usd - 3.0).abs() < f64::EPSILON,
284 "{text}"
285 );
286 }
287 }
288}