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}