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