Skip to main content

cc_token_usage/analysis/
overview.rs

1use std::collections::HashMap;
2
3use chrono::{Datelike, Timelike};
4
5use crate::data::models::{GlobalDataQuality, SessionData, ValidatedTurn};
6use crate::pricing::calculator::PricingCalculator;
7
8use super::{
9    AggregatedTokens, CacheSavings, CostByCategory, OverviewResult, SessionSummary,
10    SubscriptionValue, TurnCostBreakdown, TurnDetail,
11};
12
13pub fn analyze_overview(
14    sessions: &[SessionData],
15    quality: GlobalDataQuality,
16    calc: &PricingCalculator,
17    subscription_price: Option<f64>,
18) -> OverviewResult {
19    let mut tokens_by_model: HashMap<String, AggregatedTokens> = HashMap::new();
20    let mut cost_by_model: HashMap<String, f64> = HashMap::new();
21    let mut total_cost = 0.0;
22    let mut hourly_distribution = [0usize; 24];
23    let mut weekday_hour_matrix = [[0usize; 24]; 7];
24    let mut total_turns = 0usize;
25    let mut total_agent_turns = 0usize;
26    let mut cost_by_category = CostByCategory::default();
27
28    for session in sessions {
29        // Process regular turns
30        for turn in &session.turns {
31            process_turn(
32                turn,
33                calc,
34                &mut tokens_by_model,
35                &mut cost_by_model,
36                &mut total_cost,
37                &mut hourly_distribution,
38                &mut weekday_hour_matrix,
39                &mut cost_by_category,
40            );
41            total_turns += 1;
42        }
43
44        // Process agent turns
45        for turn in &session.agent_turns {
46            process_turn(
47                turn,
48                calc,
49                &mut tokens_by_model,
50                &mut cost_by_model,
51                &mut total_cost,
52                &mut hourly_distribution,
53                &mut weekday_hour_matrix,
54                &mut cost_by_category,
55            );
56            total_turns += 1;
57            total_agent_turns += 1;
58        }
59    }
60
61    // Compute totals from tokens_by_model
62    let mut total_output_tokens: u64 = 0;
63    let mut total_context_tokens: u64 = 0;
64    for agg in tokens_by_model.values() {
65        total_output_tokens += agg.output_tokens;
66        total_context_tokens += agg.context_tokens();
67    }
68
69    // Average cache hit rate
70    let total_cache_read: u64 = tokens_by_model.values().map(|a| a.cache_read_tokens).sum();
71    let avg_cache_hit_rate = if total_context_tokens > 0 {
72        (total_cache_read as f64 / total_context_tokens as f64) * 100.0
73    } else {
74        0.0
75    };
76
77    // Build session summaries
78    let mut session_summaries: Vec<SessionSummary> = sessions
79        .iter()
80        .map(|s| build_session_summary(s, calc))
81        .collect();
82
83    // Populate turn_details for all sessions
84    for (idx, session) in sessions.iter().enumerate() {
85        let details = build_turn_details(session, calc);
86        session_summaries[idx].turn_details = Some(details);
87    }
88
89    // ── Cache savings calculation ───────────────────────────────────────────
90    // Savings = what cache_read tokens would cost at base_input rate minus actual cache_read cost
91    let cache_savings = {
92        let mut total_saved = 0.0f64;
93        let mut without_cache = 0.0f64;
94        let mut with_cache = 0.0f64;
95        let mut by_model: Vec<(String, f64)> = Vec::new();
96
97        for (model, tokens) in &tokens_by_model {
98            if let Some((price, _)) = calc.get_price(model) {
99                let cache_read_mtok = tokens.cache_read_tokens as f64 / 1_000_000.0;
100                let hypothetical = cache_read_mtok * price.base_input;
101                let actual = cache_read_mtok * price.cache_read;
102                let saved = hypothetical - actual;
103                without_cache += hypothetical;
104                with_cache += actual;
105                total_saved += saved;
106                if saved > 0.01 {
107                    by_model.push((model.clone(), saved));
108                }
109            }
110        }
111        by_model.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
112
113        let savings_pct = if without_cache > 0.0 {
114            total_saved / without_cache * 100.0
115        } else {
116            0.0
117        };
118
119        CacheSavings {
120            total_saved,
121            without_cache_cost: without_cache,
122            with_cache_cost: with_cache,
123            savings_pct,
124            by_model,
125        }
126    };
127
128    let subscription_value = subscription_price.map(|monthly_price| {
129        let value_multiplier = if total_cost > 0.0 {
130            total_cost / monthly_price
131        } else {
132            0.0
133        };
134        SubscriptionValue {
135            monthly_price,
136            api_equivalent: total_cost,
137            value_multiplier,
138        }
139    });
140
141    OverviewResult {
142        total_sessions: sessions.len(),
143        total_turns,
144        total_agent_turns,
145        tokens_by_model,
146        cost_by_model,
147        total_cost,
148        hourly_distribution,
149        quality,
150        subscription_value,
151        weekday_hour_matrix,
152        tool_counts: Vec::new(), // No tool name data available in ValidatedTurn
153        cost_by_category,
154        session_summaries,
155        total_output_tokens,
156        total_context_tokens,
157        avg_cache_hit_rate,
158        cache_savings,
159    }
160}
161
162#[allow(clippy::too_many_arguments)]
163fn process_turn(
164    turn: &ValidatedTurn,
165    calc: &PricingCalculator,
166    tokens_by_model: &mut HashMap<String, AggregatedTokens>,
167    cost_by_model: &mut HashMap<String, f64>,
168    total_cost: &mut f64,
169    hourly_distribution: &mut [usize; 24],
170    weekday_hour_matrix: &mut [[usize; 24]; 7],
171    cost_by_category: &mut CostByCategory,
172) {
173    // Aggregate tokens by model
174    tokens_by_model
175        .entry(turn.model.clone())
176        .or_default()
177        .add_usage(&turn.usage);
178
179    // Calculate cost
180    let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
181    *cost_by_model.entry(turn.model.clone()).or_insert(0.0) += cost.total;
182    *total_cost += cost.total;
183
184    // Accumulate cost by category
185    cost_by_category.input_cost += cost.input_cost;
186    cost_by_category.output_cost += cost.output_cost;
187    cost_by_category.cache_write_5m_cost += cost.cache_write_5m_cost;
188    cost_by_category.cache_write_1h_cost += cost.cache_write_1h_cost;
189    cost_by_category.cache_read_cost += cost.cache_read_cost;
190
191    // Hourly distribution
192    let hour = turn.timestamp.hour() as usize;
193    hourly_distribution[hour] += 1;
194
195    // Weekday-hour matrix
196    let weekday = turn.timestamp.weekday().num_days_from_monday() as usize; // 0=Mon..6=Sun
197    weekday_hour_matrix[weekday][hour] += 1;
198}
199
200/// Build a SessionSummary for a single session.
201fn build_session_summary(session: &SessionData, calc: &PricingCalculator) -> SessionSummary {
202    let session_id = if session.session_id.len() > 8 {
203        session.session_id[..8].to_string()
204    } else {
205        session.session_id.clone()
206    };
207
208    let project_display_name = session
209        .project
210        .as_deref()
211        .map(crate::analysis::project::project_display_name)
212        .unwrap_or_else(|| "(unknown)".to_string());
213
214    // Merge and sort all turns by timestamp
215    let mut all_turns: Vec<(&ValidatedTurn, bool)> = Vec::new();
216    for turn in &session.turns {
217        all_turns.push((turn, false));
218    }
219    for turn in &session.agent_turns {
220        all_turns.push((turn, true));
221    }
222    all_turns.sort_by_key(|(t, _)| t.timestamp);
223
224    let turn_count = all_turns.len();
225
226    // Duration
227    let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
228        (Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
229        _ => 0.0,
230    };
231
232    // Model frequency, output/context tokens, max_context, cache stats, compaction, cost
233    let mut model_counts: HashMap<&str, usize> = HashMap::new();
234    let mut output_tokens: u64 = 0;
235    let mut context_tokens: u64 = 0;
236    let mut max_context: u64 = 0;
237    let mut total_cache_read: u64 = 0;
238    let mut total_context: u64 = 0;
239    let mut total_5m: u64 = 0;
240    let mut total_1h: u64 = 0;
241    let mut compaction_count: usize = 0;
242    let mut agent_turn_count: usize = 0;
243    let mut tool_use_count: usize = 0;
244    let mut total_cost: f64 = 0.0;
245    let mut prev_context_size: Option<u64> = None;
246
247    for (turn, is_from_agent_file) in &all_turns {
248        *model_counts.entry(&turn.model).or_insert(0) += 1;
249
250        let input = turn.usage.input_tokens.unwrap_or(0);
251        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
252        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
253        let out = turn.usage.output_tokens.unwrap_or(0);
254
255        output_tokens += out;
256        let ctx = input + cache_create + cache_read;
257        context_tokens += ctx;
258        total_context += ctx;
259        total_cache_read += cache_read;
260
261        if ctx > max_context {
262            max_context = ctx;
263        }
264
265        // TTL breakdown
266        if let Some(ref detail) = turn.usage.cache_creation {
267            total_5m += detail.ephemeral_5m_input_tokens.unwrap_or(0);
268            total_1h += detail.ephemeral_1h_input_tokens.unwrap_or(0);
269        }
270
271        // Compaction detection
272        if let Some(prev) = prev_context_size {
273            if prev > 0 && (ctx as f64) < (prev as f64 * 0.9) {
274                compaction_count += 1;
275            }
276        }
277        prev_context_size = Some(ctx);
278
279        // Agent turns
280        if turn.is_agent || *is_from_agent_file {
281            agent_turn_count += 1;
282        }
283
284        // Tool use count
285        if turn.stop_reason.as_deref() == Some("tool_use") {
286            tool_use_count += 1;
287        }
288
289        // Cost
290        let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
291        total_cost += cost.total;
292    }
293
294    // Primary model
295    let model = model_counts
296        .into_iter()
297        .max_by_key(|(_, count)| *count)
298        .map(|(m, _)| m.to_string())
299        .unwrap_or_default();
300
301    // Cache hit rate
302    let cache_hit_rate = if total_context > 0 {
303        (total_cache_read as f64 / total_context as f64) * 100.0
304    } else {
305        0.0
306    };
307
308    // Cache write 5m percentage
309    let total_cache_write = total_5m + total_1h;
310    let cache_write_5m_pct = if total_cache_write > 0 {
311        (total_5m as f64 / total_cache_write as f64) * 100.0
312    } else {
313        0.0
314    };
315
316    SessionSummary {
317        session_id,
318        project_display_name,
319        first_timestamp: session.first_timestamp,
320        duration_minutes,
321        model,
322        turn_count,
323        agent_turn_count,
324        output_tokens,
325        context_tokens,
326        max_context,
327        cache_hit_rate,
328        cache_write_5m_pct,
329        compaction_count,
330        cost: total_cost,
331        tool_use_count,
332        top_tools: Vec::new(), // No tool name data available
333        turn_details: None,
334    }
335}
336
337/// Build turn-level details for a session (used by HTML report for expandable rows).
338fn build_turn_details(session: &SessionData, calc: &PricingCalculator) -> Vec<TurnDetail> {
339    let mut all_turns: Vec<(&ValidatedTurn, bool)> = Vec::new();
340    for turn in &session.turns {
341        all_turns.push((turn, false));
342    }
343    for turn in &session.agent_turns {
344        all_turns.push((turn, true));
345    }
346    all_turns.sort_by_key(|(t, _)| t.timestamp);
347
348    let mut details = Vec::new();
349    let mut prev_context_size: Option<u64> = None;
350
351    for (i, (turn, is_from_agent_file)) in all_turns.iter().enumerate() {
352        let input = turn.usage.input_tokens.unwrap_or(0);
353        let output = turn.usage.output_tokens.unwrap_or(0);
354        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
355        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
356
357        let (cache_write_5m, cache_write_1h) = if let Some(ref detail) = turn.usage.cache_creation {
358            (
359                detail.ephemeral_5m_input_tokens.unwrap_or(0),
360                detail.ephemeral_1h_input_tokens.unwrap_or(0),
361            )
362        } else {
363            (0, 0)
364        };
365
366        let context_size = input + cache_create + cache_read;
367        let cache_hit_rate = if context_size > 0 {
368            (cache_read as f64 / context_size as f64) * 100.0
369        } else {
370            0.0
371        };
372
373        let is_compaction = match prev_context_size {
374            Some(prev) => prev > 0 && (context_size as f64) < (prev as f64 * 0.9),
375            None => false,
376        };
377        let context_delta = match prev_context_size {
378            Some(prev) => context_size as i64 - prev as i64,
379            None => 0,
380        };
381        prev_context_size = Some(context_size);
382
383        let pricing_cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
384
385        let cost_breakdown = TurnCostBreakdown {
386            input_cost: pricing_cost.input_cost,
387            output_cost: pricing_cost.output_cost,
388            cache_write_5m_cost: pricing_cost.cache_write_5m_cost,
389            cache_write_1h_cost: pricing_cost.cache_write_1h_cost,
390            cache_read_cost: pricing_cost.cache_read_cost,
391            total: pricing_cost.total,
392        };
393
394        let is_agent = turn.is_agent || *is_from_agent_file;
395
396        details.push(TurnDetail {
397            turn_number: i + 1,
398            timestamp: turn.timestamp,
399            model: turn.model.clone(),
400            input_tokens: input,
401            output_tokens: output,
402            cache_write_5m_tokens: cache_write_5m,
403            cache_write_1h_tokens: cache_write_1h,
404            cache_read_tokens: cache_read,
405            context_size,
406            cache_hit_rate,
407            cost: pricing_cost.total,
408            cost_breakdown,
409            stop_reason: turn.stop_reason.clone(),
410            is_agent,
411            is_compaction,
412            context_delta,
413            user_text: turn.user_text.clone(),
414            assistant_text: turn.assistant_text.clone(),
415            tool_names: turn.tool_names.clone(),
416        });
417    }
418
419    details
420}