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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! 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,
/// BP-7 (catalog §4a "Per-turn cost/usage accounting" — the COST half
/// the row's semantics name alongside tokens): this round-trip's dollar
/// cost at the model's resolved [`crate::pricing::ModelPrice`].
/// `None` when this build cannot price the model — never a guess, and
/// never zero standing in for "unknown".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
/// BP-13 (D9 "Model-served-vs-requested provenance"): the model the
/// PROVIDER reported as having produced the response, when it reported
/// one at all. [`Self::model`] above is what was REQUESTED; these two
/// can genuinely differ (a gateway resolving a floating name to a dated
/// snapshot, a routed tier, a fallback hop), and a record that carries
/// only the request can never show it. `None` means the provider said
/// nothing — never "they matched".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub served_model: Option<String>,
}
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,
cost_usd: None,
served_model: None,
}
}
/// BP-7: the same record with `cost_usd` filled in from `price`, or
/// unchanged when the model has no resolvable price.
pub fn priced(mut self, price: Option<crate::pricing::ModelPrice>) -> UsageRecord {
self.cost_usd = price.map(|p| p.cost_usd(self.prompt_tokens, self.completion_tokens));
self
}
/// BP-13: attach the provider-reported serving model. `None` leaves the
/// record saying nothing about it, which is the honest reading when the
/// response carried no `model` field.
pub fn with_served_model(mut self, served: Option<String>) -> UsageRecord {
self.served_model = served;
self
}
/// BP-13: whether the model that answered differs from the one asked
/// for. `false` when the provider reported nothing — an unknown is not
/// a divergence.
pub fn diverged(&self) -> bool {
self.served_model
.as_deref()
.is_some_and(|served| served != self.model)
}
}
/// 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 priced_fills_cost_only_when_a_price_resolves() {
let usage = Usage {
prompt_tokens: 1_000_000,
completion_tokens: 0,
total_tokens: 1_000_000,
prompt_tokens_details: None,
};
let record = UsageRecord::from_usage(0, "anthropic/claude-opus-4-8", &usage, 0);
assert_eq!(record.cost_usd, None, "unpriced until `priced` is called");
let priced = record
.clone()
.priced(crate::pricing::built_in(&record.model));
assert_eq!(
priced.cost_usd,
Some(crate::pricing_ref::REF_INPUT_PER_MTOK)
);
assert_eq!(record.priced(None).cost_usd, None);
}
#[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,
cost_usd: Some(0.018_75),
served_model: Some("anthropic/claude-opus-4-8-20260101".to_string()),
},
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,
cost_usd: None,
served_model: None,
},
];
let jsonl = to_jsonl(&records).unwrap();
let round_tripped = from_jsonl(&jsonl).unwrap();
assert_eq!(records, round_tripped);
}
/// BP-13: the served model rides the record when the provider reported
/// one, and its ABSENCE is not read as agreement.
#[test]
fn served_model_records_divergence_and_never_infers_agreement() {
let usage = Usage::default();
let asked = UsageRecord::from_usage(0, "vendor/floating", &usage, 0);
assert_eq!(asked.served_model, None);
assert!(!asked.diverged(), "an unknown is not a divergence");
let served = asked
.clone()
.with_served_model(Some("vendor/floating-20260101".to_string()));
assert!(served.diverged());
let same = UsageRecord::from_usage(0, "vendor/m", &usage, 0)
.with_served_model(Some("vendor/m".to_string()));
assert!(!same.diverged());
// A record written before this field existed still parses.
let old = r#"{"turn":0,"model":"m","prompt_tokens":1}"#;
assert_eq!(from_jsonl(old).unwrap()[0].served_model, None);
}
#[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());
}
}