Skip to main content

layover_tower/
cost.rs

1//! Reading what a run cost out of what it printed.
2//!
3//! # Why this is allowed to fail, and what it does when it does
4//!
5//! Fuel and the Reserve are debited from a figure the child prints about itself. Layover does not
6//! control any of those output formats: they belong to three CLIs that version independently and
7//! may change shape without warning.
8//!
9//! So the design assumption is that parsing *will* break, and the question is what happens when it
10//! does. A parser that returned zero on failure would be the worst possible answer — spending
11//! would continue, the rails would never trip, and every total would look precise. Instead an
12//! unreadable figure is [`CostSource::Unreported`], which already means "this number is a lower
13//! bound" everywhere it is displayed, and the run cap — which needs no cooperation from the child —
14//! becomes the rail that still holds.
15//!
16//! # Why a plausible-looking figure can still be rejected
17//!
18//! A runner reporting a cent for a four-dollar run is believed by arithmetic and wrong in fact,
19//! and both money rails then under-count by the same factor. Where token counts are also reported,
20//! they are a second opinion: a cost more than an order of magnitude below what those tokens imply
21//! is treated as unreported rather than as a measurement.
22
23use layover_core::cost::CostSource;
24use serde::Deserialize;
25
26/// What a run reported about its own cost.
27#[derive(Debug, Clone, PartialEq)]
28pub struct Reported {
29    /// The figure, in dollars.
30    pub usd: f64,
31    /// Where it came from, and therefore how much it can be trusted.
32    pub source: CostSource,
33    /// Tokens in, when the runner said.
34    pub input_tokens: u64,
35    /// Tokens out, when the runner said.
36    pub output_tokens: u64,
37}
38
39impl Reported {
40    /// A run whose cost could not be read.
41    ///
42    /// Zero dollars, because nothing may be debited — but explicitly unreported, so no total built
43    /// from it ever claims to be measured.
44    #[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/// The shapes the supported CLIs emit.
56///
57/// Deliberately permissive about field names and strict about types. Every one of these is a
58/// different CLI's idea of the same fact, and none of them promised to keep it.
59#[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
74/// Roughly what a dollar buys, used only to sanity-check a reported figure.
75///
76/// Not a rate card and not used to price anything — a single order-of-magnitude yardstick for
77/// deciding whether a claimed cost is in the right universe. Deliberately generous: the job is to
78/// catch a runner claiming a cent for a four-dollar run, not to second-guess pricing.
79const TOKENS_PER_DOLLAR: f64 = 1_000_000.0;
80
81/// Reads the last cost a transcript reports.
82///
83/// The last, not the first: agent CLIs emit running totals, and the final one is the total. Lines
84/// that are not JSON are skipped, because a transcript is mostly the agent talking.
85#[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            // Tokens without a cost is still worth keeping: it is what makes a later cost
104            // checkable, and on its own it says a run did work it could not price.
105            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
122/// Decides whether a reported figure can be believed.
123fn judge(usd: f64, input: u64, output: u64) -> Reported {
124    let tokens = input + output;
125
126    // Negative, NaN and infinite are not measurements. Nothing may be debited from them, and
127    // pretending otherwise would let a runner credit Fuel back to itself.
128    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    // Zero dollars alongside real tokens is silence, not a measurement: work was done and the
138    // runner did not price it.
139    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    // A figure an order of magnitude below what the tokens imply is not believable. Treated as
149    // unreported rather than corrected: a better guess is still a guess, and saying "this is a
150    // lower bound" is the honest answer.
151    if tokens > 0 {
152        // Token counts are millions at most, far below the 2^53 an f64 represents exactly.
153        #[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        // The difference matters: zero-and-measured would let a chain run forever.
208        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        // Risk 18: a runner reporting a cent for a four-dollar run defeats Fuel and the Reserve
236        // together, because both read the same figure.
237        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        // The check is an order of magnitude, not a price list. A cheap model must not be
250        // constantly accused of lying.
251        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        // The assumption is that these formats will break. What must not happen is that one bad
269        // line loses a cost the runner did report.
270        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        // Three CLIs, three spellings of the same fact.
277        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}