Skip to main content

supercode_harness/
usage_log.rs

1//! P4b (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.6/§3.1, catalog §4a
2//! "Turn/step usage records surfaced per turn"): persisted per-turn
3//! token/usage records. [`crate::AgentEvent::Usage`] already streams this
4//! data live (UX-23); this module makes it DURABLE session data — a typed,
5//! serde-round-trippable record, not a lossy display-only channel (§1.13's
6//! lossless/sidecar discipline: this is typed session data, exactly like
7//! [`crate::reduce::ReductionLog`], not a text notice).
8
9use serde::{Deserialize, Serialize};
10
11use crate::provider::Usage;
12
13/// One model round-trip's token accounting, with the context a bare
14/// [`Usage`] lacks: which turn it was and which model served it (D9 "model
15/// provenance" — obligation 6's "served-model provenance" row). Deliberately
16/// flat/typed (not a formatted string) so it survives a save/load round trip
17/// byte-for-byte in the fields that matter, and so a future reader (a
18/// `doctor`/`inspect stats` command, a cost dashboard) can aggregate it
19/// without re-parsing text.
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
21pub struct UsageRecord {
22    /// 0-based index of the model round-trip this record covers (one per
23    /// [`crate::AgentEvent::TurnCompleted`], the same cadence `UX-23`
24    /// already uses).
25    pub turn: usize,
26    /// The model id that served this turn (`Config.model`, or the
27    /// mid-session-switched model once obligation 10 lands — recorded
28    /// per-turn rather than once per session so a handoff is visible in the
29    /// log, not just implied).
30    pub model: String,
31    /// Input tokens.
32    #[serde(default)]
33    pub prompt_tokens: u64,
34    /// Output tokens.
35    #[serde(default)]
36    pub completion_tokens: u64,
37    /// Total tokens (provider-reported; not always `prompt + completion`
38    /// exactly, so kept as its own field rather than derived).
39    #[serde(default)]
40    pub total_tokens: u64,
41    /// Prompt tokens served from the provider's cache (B7), if reported.
42    #[serde(default)]
43    pub cached_tokens: Option<u64>,
44    /// Unix-ms wall-clock time the record was created.
45    #[serde(default)]
46    pub timestamp_ms: i64,
47    /// BP-7 (catalog §4a "Per-turn cost/usage accounting" — the COST half
48    /// the row's semantics name alongside tokens): this round-trip's dollar
49    /// cost at the model's resolved [`crate::pricing::ModelPrice`].
50    /// `None` when this build cannot price the model — never a guess, and
51    /// never zero standing in for "unknown".
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub cost_usd: Option<f64>,
54    /// BP-13 (D9 "Model-served-vs-requested provenance"): the model the
55    /// PROVIDER reported as having produced the response, when it reported
56    /// one at all. [`Self::model`] above is what was REQUESTED; these two
57    /// can genuinely differ (a gateway resolving a floating name to a dated
58    /// snapshot, a routed tier, a fallback hop), and a record that carries
59    /// only the request can never show it. `None` means the provider said
60    /// nothing — never "they matched".
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub served_model: Option<String>,
63}
64
65impl UsageRecord {
66    /// Build a record from a provider [`Usage`] plus the per-turn context a
67    /// bare `Usage` doesn't carry.
68    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    /// BP-7: the same record with `cost_usd` filled in from `price`, or
83    /// unchanged when the model has no resolvable price.
84    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    /// BP-13: attach the provider-reported serving model. `None` leaves the
90    /// record saying nothing about it, which is the honest reading when the
91    /// response carried no `model` field.
92    pub fn with_served_model(mut self, served: Option<String>) -> UsageRecord {
93        self.served_model = served;
94        self
95    }
96
97    /// BP-13: whether the model that answered differs from the one asked
98    /// for. `false` when the provider reported nothing — an unknown is not
99    /// a divergence.
100    pub fn diverged(&self) -> bool {
101        self.served_model
102            .as_deref()
103            .is_some_and(|served| served != self.model)
104    }
105}
106
107/// Serialize `records` as JSONL (one [`UsageRecord`] per line) — the same
108/// shape every other append-log in this crate uses (the transcript, the
109/// sidecar). Never fails on an empty slice (produces an empty string).
110pub 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
119/// Parse a JSONL usage log back into records — the exact inverse of
120/// [`to_jsonl`]. Blank lines are skipped (tolerates a trailing newline or
121/// hand-edited whitespace); a malformed line is a hard error (unlike the
122/// hooks/sidecar "fail open" posture — a corrupt usage record should be
123/// visible, not silently dropped, since it's accounting data).
124pub 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    /// BP-13: the served model rides the record when the provider reported
223    /// one, and its ABSENCE is not read as agreement.
224    #[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        // A record written before this field existed still parses.
238        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}