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}
48
49impl UsageRecord {
50    /// Build a record from a provider [`Usage`] plus the per-turn context a
51    /// bare `Usage` doesn't carry.
52    pub fn from_usage(turn: usize, model: &str, usage: &Usage, timestamp_ms: i64) -> UsageRecord {
53        UsageRecord {
54            turn,
55            model: model.to_string(),
56            prompt_tokens: usage.prompt_tokens,
57            completion_tokens: usage.completion_tokens,
58            total_tokens: usage.total_tokens,
59            cached_tokens: usage.prompt_tokens_details.map(|d| d.cached_tokens),
60            timestamp_ms,
61        }
62    }
63}
64
65/// Serialize `records` as JSONL (one [`UsageRecord`] per line) — the same
66/// shape every other append-log in this crate uses (the transcript, the
67/// sidecar). Never fails on an empty slice (produces an empty string).
68pub fn to_jsonl(records: &[UsageRecord]) -> crate::Result<String> {
69    let mut out = String::new();
70    for r in records {
71        out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
72        out.push('\n');
73    }
74    Ok(out)
75}
76
77/// Parse a JSONL usage log back into records — the exact inverse of
78/// [`to_jsonl`]. Blank lines are skipped (tolerates a trailing newline or
79/// hand-edited whitespace); a malformed line is a hard error (unlike the
80/// hooks/sidecar "fail open" posture — a corrupt usage record should be
81/// visible, not silently dropped, since it's accounting data).
82pub fn from_jsonl(text: &str) -> crate::Result<Vec<UsageRecord>> {
83    let mut out = Vec::new();
84    for line in text.lines() {
85        let line = line.trim();
86        if line.is_empty() {
87            continue;
88        }
89        out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
90    }
91    Ok(out)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::provider::PromptTokensDetails;
98
99    #[test]
100    fn from_usage_carries_every_field() {
101        let usage = Usage {
102            prompt_tokens: 100,
103            completion_tokens: 20,
104            total_tokens: 120,
105            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 40 }),
106        };
107        let r = UsageRecord::from_usage(2, "anthropic/claude-opus-4-8", &usage, 1_700_000_000_000);
108        assert_eq!(r.turn, 2);
109        assert_eq!(r.model, "anthropic/claude-opus-4-8");
110        assert_eq!(r.prompt_tokens, 100);
111        assert_eq!(r.completion_tokens, 20);
112        assert_eq!(r.total_tokens, 120);
113        assert_eq!(r.cached_tokens, Some(40));
114        assert_eq!(r.timestamp_ms, 1_700_000_000_000);
115    }
116
117    #[test]
118    fn from_usage_with_no_cache_details_is_none() {
119        let usage = Usage {
120            prompt_tokens: 10,
121            completion_tokens: 5,
122            total_tokens: 15,
123            prompt_tokens_details: None,
124        };
125        let r = UsageRecord::from_usage(0, "m", &usage, 0);
126        assert_eq!(r.cached_tokens, None);
127    }
128
129    #[test]
130    fn jsonl_round_trip_is_lossless() {
131        let records = vec![
132            UsageRecord {
133                turn: 0,
134                model: "anthropic/claude-opus-4-8".to_string(),
135                prompt_tokens: 1000,
136                completion_tokens: 50,
137                total_tokens: 1050,
138                cached_tokens: Some(200),
139                timestamp_ms: 1_700_000_000_000,
140            },
141            UsageRecord {
142                turn: 1,
143                model: "anthropic/claude-haiku-4-5".to_string(),
144                prompt_tokens: 2000,
145                completion_tokens: 75,
146                total_tokens: 2075,
147                cached_tokens: None,
148                timestamp_ms: 1_700_000_005_000,
149            },
150        ];
151        let jsonl = to_jsonl(&records).unwrap();
152        let round_tripped = from_jsonl(&jsonl).unwrap();
153        assert_eq!(records, round_tripped);
154    }
155
156    #[test]
157    fn empty_records_round_trip_to_empty() {
158        assert_eq!(to_jsonl(&[]).unwrap(), "");
159        assert_eq!(from_jsonl("").unwrap(), Vec::<UsageRecord>::new());
160    }
161
162    #[test]
163    fn from_jsonl_skips_blank_lines() {
164        let text = "\n\n";
165        assert_eq!(from_jsonl(text).unwrap(), Vec::<UsageRecord>::new());
166    }
167}