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
//! 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)
}