1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
//! P4b (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.6/§3.1, catalog §4a
//! "Turn/step usage records surfaced per turn"): persisted per-turn
//! token/usage records. [`crate::AgentEvent::Usage`] already streams this
//! data live (UX-23); this module makes it DURABLE session data — a typed,
//! serde-round-trippable record, not a lossy display-only channel (§1.13's
//! lossless/sidecar discipline: this is typed session data, exactly like
//! [`crate::reduce::ReductionLog`], not a text notice).
use serde::{Deserialize, Serialize};
use crate::provider::Usage;
/// One model round-trip's token accounting, with the context a bare
/// [`Usage`] lacks: which turn it was and which model served it (D9 "model
/// provenance" — obligation 6's "served-model provenance" row). Deliberately
/// flat/typed (not a formatted string) so it survives a save/load round trip
/// byte-for-byte in the fields that matter, and so a future reader (a
/// `doctor`/`inspect stats` command, a cost dashboard) can aggregate it
/// without re-parsing text.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UsageRecord {
/// 0-based index of the model round-trip this record covers (one per
/// [`crate::AgentEvent::TurnCompleted`], the same cadence `UX-23`
/// already uses).
pub turn: usize,
/// The model id that served this turn (`Config.model`, or the
/// mid-session-switched model once obligation 10 lands — recorded
/// per-turn rather than once per session so a handoff is visible in the
/// log, not just implied).
pub model: String,
/// Input tokens.
#[serde(default)]
pub prompt_tokens: u64,
/// Output tokens.
#[serde(default)]
pub completion_tokens: u64,
/// Total tokens (provider-reported; not always `prompt + completion`
/// exactly, so kept as its own field rather than derived).
#[serde(default)]
pub total_tokens: u64,
/// Prompt tokens served from the provider's cache (B7), if reported.
#[serde(default)]
pub cached_tokens: Option<u64>,
/// Unix-ms wall-clock time the record was created.
#[serde(default)]
pub timestamp_ms: i64,
}
impl UsageRecord {
/// Build a record from a provider [`Usage`] plus the per-turn context a
/// bare `Usage` doesn't carry.
pub fn from_usage(turn: usize, model: &str, usage: &Usage, timestamp_ms: i64) -> UsageRecord {
UsageRecord {
turn,
model: model.to_string(),
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
cached_tokens: usage.prompt_tokens_details.map(|d| d.cached_tokens),
timestamp_ms,
}
}
}
/// Serialize `records` as JSONL (one [`UsageRecord`] per line) — the same
/// shape every other append-log in this crate uses (the transcript, the
/// sidecar). Never fails on an empty slice (produces an empty string).
pub fn to_jsonl(records: &[UsageRecord]) -> crate::Result<String> {
let mut out = String::new();
for r in records {
out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
out.push('\n');
}
Ok(out)
}
/// Parse a JSONL usage log back into records — the exact inverse of
/// [`to_jsonl`]. Blank lines are skipped (tolerates a trailing newline or
/// hand-edited whitespace); a malformed line is a hard error (unlike the
/// hooks/sidecar "fail open" posture — a corrupt usage record should be
/// visible, not silently dropped, since it's accounting data).
pub fn from_jsonl(text: &str) -> crate::Result<Vec<UsageRecord>> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::PromptTokensDetails;
#[test]
fn from_usage_carries_every_field() {
let usage = Usage {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 40 }),
};
let r = UsageRecord::from_usage(2, "anthropic/claude-opus-4-8", &usage, 1_700_000_000_000);
assert_eq!(r.turn, 2);
assert_eq!(r.model, "anthropic/claude-opus-4-8");
assert_eq!(r.prompt_tokens, 100);
assert_eq!(r.completion_tokens, 20);
assert_eq!(r.total_tokens, 120);
assert_eq!(r.cached_tokens, Some(40));
assert_eq!(r.timestamp_ms, 1_700_000_000_000);
}
#[test]
fn from_usage_with_no_cache_details_is_none() {
let usage = Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: None,
};
let r = UsageRecord::from_usage(0, "m", &usage, 0);
assert_eq!(r.cached_tokens, None);
}
#[test]
fn jsonl_round_trip_is_lossless() {
let records = vec![
UsageRecord {
turn: 0,
model: "anthropic/claude-opus-4-8".to_string(),
prompt_tokens: 1000,
completion_tokens: 50,
total_tokens: 1050,
cached_tokens: Some(200),
timestamp_ms: 1_700_000_000_000,
},
UsageRecord {
turn: 1,
model: "anthropic/claude-haiku-4-5".to_string(),
prompt_tokens: 2000,
completion_tokens: 75,
total_tokens: 2075,
cached_tokens: None,
timestamp_ms: 1_700_000_005_000,
},
];
let jsonl = to_jsonl(&records).unwrap();
let round_tripped = from_jsonl(&jsonl).unwrap();
assert_eq!(records, round_tripped);
}
#[test]
fn empty_records_round_trip_to_empty() {
assert_eq!(to_jsonl(&[]).unwrap(), "");
assert_eq!(from_jsonl("").unwrap(), Vec::<UsageRecord>::new());
}
#[test]
fn from_jsonl_skips_blank_lines() {
let text = "\n\n";
assert_eq!(from_jsonl(text).unwrap(), Vec::<UsageRecord>::new());
}
}