Skip to main content

lash_core/runtime/
usage.rs

1//! Token usage accounting: ledger entries, usage totals, reports, and diff helpers.
2//!
3//! Extracted from `runtime/mod.rs` as part of the runtime split. All items
4//! keep their original public paths via `pub use` in `mod.rs` — no API
5//! changes.
6
7use std::collections::{BTreeMap, HashMap};
8
9use crate::session_model::TokenUsage;
10use lash_sansio::PromptUsage;
11
12/// A single row in the token cost ledger. One per unique
13/// `(source, model)` pair — accumulated, not per-call.
14#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
15pub struct TokenLedgerEntry {
16    /// Caller-supplied label: `"turn"`, `"subagent"`, `"compaction"`,
17    /// `"observer"`, `"reflector"`, or any plugin-defined
18    /// string. Core doesn't interpret the value; the UI uses it for
19    /// grouping and display.
20    pub source: String,
21    /// Model identifier used for the LLM call (e.g.
22    /// `"anthropic/claude-haiku-4-5"`).
23    pub model: String,
24    /// Accumulated token counts for this `(source, model)` pair.
25    pub usage: TokenUsage,
26}
27
28/// Aggregated usage for a report row: the canonical [`TokenUsage`] counters
29/// plus a precomputed `total_tokens` so JSON consumers don't recompute the sum.
30/// `TokenUsage` is embedded (flattened) rather than re-declared so a new counter
31/// tier is added in exactly one place and automatically flows through here.
32#[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}