1use std::collections::{BTreeMap, HashMap};
8
9use crate::session_model::TokenUsage;
10use lash_sansio::PromptUsage;
11
12#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
15pub struct TokenLedgerEntry {
16 pub source: String,
21 pub model: String,
24 pub usage: TokenUsage,
26}
27
28#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
29pub struct UsageTotals {
30 pub input_tokens: i64,
31 pub output_tokens: i64,
32 pub cache_read_input_tokens: i64,
33 #[serde(default)]
34 pub cache_write_input_tokens: i64,
35 #[serde(default)]
36 pub reasoning_output_tokens: i64,
37 pub total_tokens: i64,
38 pub context_total_tokens: i64,
39}
40
41impl UsageTotals {
42 fn from_usage(usage: &TokenUsage) -> Self {
43 let total_tokens = usage.total();
44 Self {
45 input_tokens: usage.input_tokens,
46 output_tokens: usage.output_tokens,
47 cache_read_input_tokens: usage.cache_read_input_tokens,
48 cache_write_input_tokens: usage.cache_write_input_tokens,
49 reasoning_output_tokens: usage.reasoning_output_tokens,
50 total_tokens,
51 context_total_tokens: total_tokens,
52 }
53 }
54}
55
56#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
57pub struct UsageReportRow {
58 pub source: String,
59 pub model: String,
60 pub usage: UsageTotals,
61}
62
63#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
64pub struct SessionUsageReport {
65 pub entry_count: usize,
66 pub usage: UsageTotals,
67 pub by_source: BTreeMap<String, UsageTotals>,
68 pub by_model: BTreeMap<String, UsageTotals>,
69 pub by_source_model: Vec<UsageReportRow>,
70}
71
72impl SessionUsageReport {
73 pub fn from_entries(entries: &[TokenLedgerEntry]) -> Self {
74 let mut total = TokenUsage::default();
75 let mut by_source_usage = BTreeMap::<String, TokenUsage>::new();
76 let mut by_model_usage = BTreeMap::<String, TokenUsage>::new();
77 let mut by_source_model = Vec::with_capacity(entries.len());
78
79 for entry in entries {
80 total.add(&entry.usage);
81 by_source_usage
82 .entry(entry.source.clone())
83 .or_default()
84 .add(&entry.usage);
85 by_model_usage
86 .entry(entry.model.clone())
87 .or_default()
88 .add(&entry.usage);
89 by_source_model.push(UsageReportRow {
90 source: entry.source.clone(),
91 model: entry.model.clone(),
92 usage: UsageTotals::from_usage(&entry.usage),
93 });
94 }
95
96 Self {
97 entry_count: entries.len(),
98 usage: UsageTotals::from_usage(&total),
99 by_source: by_source_usage
100 .into_iter()
101 .map(|(key, usage)| (key, UsageTotals::from_usage(&usage)))
102 .collect(),
103 by_model: by_model_usage
104 .into_iter()
105 .map(|(key, usage)| (key, UsageTotals::from_usage(&usage)))
106 .collect(),
107 by_source_model,
108 }
109 }
110}
111
112pub fn diff_token_ledger(
113 before: &[TokenLedgerEntry],
114 after: &[TokenLedgerEntry],
115) -> Result<Vec<TokenLedgerEntry>, String> {
116 let before_index = before
117 .iter()
118 .map(|entry| ((entry.source.as_str(), entry.model.as_str()), &entry.usage))
119 .collect::<HashMap<_, _>>();
120 let after_index = after
121 .iter()
122 .map(|entry| ((entry.source.as_str(), entry.model.as_str()), &entry.usage))
123 .collect::<HashMap<_, _>>();
124
125 let mut keys = before_index
126 .keys()
127 .copied()
128 .chain(after_index.keys().copied())
129 .collect::<Vec<_>>();
130 keys.sort_unstable();
131 keys.dedup();
132
133 let mut out = Vec::new();
134 for (source, model) in keys {
135 let before_usage = before_index
136 .get(&(source, model))
137 .copied()
138 .cloned()
139 .unwrap_or_default();
140 let after_usage = after_index
141 .get(&(source, model))
142 .copied()
143 .cloned()
144 .unwrap_or_default();
145 let delta = TokenUsage {
146 input_tokens: after_usage.input_tokens - before_usage.input_tokens,
147 output_tokens: after_usage.output_tokens - before_usage.output_tokens,
148 cache_read_input_tokens: after_usage.cache_read_input_tokens
149 - before_usage.cache_read_input_tokens,
150 cache_write_input_tokens: after_usage.cache_write_input_tokens
151 - before_usage.cache_write_input_tokens,
152 reasoning_output_tokens: after_usage.reasoning_output_tokens
153 - before_usage.reasoning_output_tokens,
154 };
155 if delta.input_tokens < 0
156 || delta.output_tokens < 0
157 || delta.cache_read_input_tokens < 0
158 || delta.cache_write_input_tokens < 0
159 || delta.reasoning_output_tokens < 0
160 {
161 return Err(format!(
162 "token ledger decreased for source/model ({source}, {model})"
163 ));
164 }
165 if delta.total() == 0 {
166 continue;
167 }
168 out.push(TokenLedgerEntry {
169 source: source.to_string(),
170 model: model.to_string(),
171 usage: delta,
172 });
173 }
174 Ok(out)
175}
176
177pub fn diff_usage_reports(
178 before: &SessionUsageReport,
179 after: &SessionUsageReport,
180) -> Result<Vec<TokenLedgerEntry>, String> {
181 let before_entries = before
182 .by_source_model
183 .iter()
184 .map(|row| TokenLedgerEntry {
185 source: row.source.clone(),
186 model: row.model.clone(),
187 usage: TokenUsage {
188 input_tokens: row.usage.input_tokens,
189 output_tokens: row.usage.output_tokens,
190 cache_read_input_tokens: row.usage.cache_read_input_tokens,
191 cache_write_input_tokens: row.usage.cache_write_input_tokens,
192 reasoning_output_tokens: row.usage.reasoning_output_tokens,
193 },
194 })
195 .collect::<Vec<_>>();
196 let after_entries = after
197 .by_source_model
198 .iter()
199 .map(|row| TokenLedgerEntry {
200 source: row.source.clone(),
201 model: row.model.clone(),
202 usage: TokenUsage {
203 input_tokens: row.usage.input_tokens,
204 output_tokens: row.usage.output_tokens,
205 cache_read_input_tokens: row.usage.cache_read_input_tokens,
206 cache_write_input_tokens: row.usage.cache_write_input_tokens,
207 reasoning_output_tokens: row.usage.reasoning_output_tokens,
208 },
209 })
210 .collect::<Vec<_>>();
211 diff_token_ledger(&before_entries, &after_entries)
212}
213
214pub(super) fn merge_ledger_entry(ledger: &mut Vec<TokenLedgerEntry>, entry: TokenLedgerEntry) {
215 if entry.usage.total() == 0 {
216 return;
217 }
218 if let Some(existing) = ledger
219 .iter_mut()
220 .find(|e| e.source == entry.source && e.model == entry.model)
221 {
222 existing.usage.input_tokens += entry.usage.input_tokens;
223 existing.usage.output_tokens += entry.usage.output_tokens;
224 existing.usage.cache_read_input_tokens += entry.usage.cache_read_input_tokens;
225 existing.usage.cache_write_input_tokens += entry.usage.cache_write_input_tokens;
226 existing.usage.reasoning_output_tokens += entry.usage.reasoning_output_tokens;
227 } else {
228 ledger.push(entry);
229 }
230}
231
232pub(super) fn merge_usage_delta_entries(entries: Vec<TokenLedgerEntry>) -> Vec<TokenLedgerEntry> {
233 let mut merged = Vec::new();
234 for entry in entries {
235 merge_ledger_entry(&mut merged, entry);
236 }
237 merged
238}
239
240pub(super) fn normalize_prompt_usage(usage: &TokenUsage) -> Option<PromptUsage> {
241 let input_tokens = usage.input_tokens.max(0) as usize;
242 let output_tokens = usage.output_tokens.max(0) as usize;
243 let cache_read_input_tokens = usage.cache_read_input_tokens.max(0) as usize;
244 let cache_write_input_tokens = usage.cache_write_input_tokens.max(0) as usize;
245 if input_tokens == 0
246 && cache_read_input_tokens == 0
247 && cache_write_input_tokens == 0
248 && output_tokens == 0
249 {
250 return None;
251 }
252
253 let prompt_context_tokens = input_tokens
254 .saturating_add(cache_read_input_tokens)
255 .saturating_add(cache_write_input_tokens);
256 let context_budget_tokens = input_tokens
257 .saturating_add(output_tokens)
258 .saturating_add(cache_read_input_tokens)
259 .saturating_add(cache_write_input_tokens);
260
261 Some(PromptUsage {
262 prompt_context_tokens,
263 input_tokens,
264 cache_read_input_tokens,
265 cache_write_input_tokens,
266 context_budget_tokens,
267 })
268}