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