supercode_harness/
usage_log.rs1use serde::{Deserialize, Serialize};
10
11use crate::provider::Usage;
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
21pub struct UsageRecord {
22 pub turn: usize,
26 pub model: String,
31 #[serde(default)]
33 pub prompt_tokens: u64,
34 #[serde(default)]
36 pub completion_tokens: u64,
37 #[serde(default)]
40 pub total_tokens: u64,
41 #[serde(default)]
43 pub cached_tokens: Option<u64>,
44 #[serde(default)]
46 pub timestamp_ms: i64,
47}
48
49impl UsageRecord {
50 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
65pub 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
77pub 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}