Skip to main content

cc_token_usage/analysis/
overview.rs

1use std::collections::HashMap;
2
3use chrono::{Datelike, Local, Timelike};
4
5use crate::data::models::{GlobalDataQuality, SessionData};
6use crate::pricing::calculator::PricingCalculator;
7
8use super::{
9    AggregatedTokens, CacheSavings, CostByCategory, OverviewResult, SessionSummary,
10    SubscriptionValue,
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 {
43                total_agent_turns += 1;
44            }
45
46            // Aggregate tool usage
47            for name in &turn.tool_names {
48                *tool_count_map.entry(name.clone()).or_insert(0) += 1;
49            }
50        }
51    }
52
53    let mut tool_counts: Vec<(String, usize)> = tool_count_map.into_iter().collect();
54    tool_counts.sort_by(|a, b| b.1.cmp(&a.1));
55
56    // Compute totals from tokens_by_model
57    let mut total_output_tokens: u64 = 0;
58    let mut total_context_tokens: u64 = 0;
59    for agg in tokens_by_model.values() {
60        total_output_tokens += agg.output_tokens;
61        total_context_tokens += agg.context_tokens();
62    }
63
64    // Average cache hit rate
65    let total_cache_read: u64 = tokens_by_model.values().map(|a| a.cache_read_tokens).sum();
66    let avg_cache_hit_rate = if total_context_tokens > 0 {
67        (total_cache_read as f64 / total_context_tokens as f64) * 100.0
68    } else {
69        0.0
70    };
71
72    // Build session summaries
73    let session_summaries: Vec<SessionSummary> = sessions
74        .iter()
75        .map(|s| build_session_summary(s, calc))
76        .collect();
77
78    // Note: turn_details intentionally left as None for overview.
79    // Individual session details are only generated for the session subcommand.
80    // This keeps the HTML report lightweight.
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    // Efficiency metrics
135    let output_ratio = if total_context_tokens > 0 {
136        total_output_tokens as f64 / total_context_tokens as f64 * 100.0
137    } else {
138        0.0
139    };
140    let cost_per_turn = if total_turns > 0 {
141        total_cost / total_turns as f64
142    } else {
143        0.0
144    };
145    let tokens_per_output_turn = if total_turns > 0 {
146        total_output_tokens / total_turns as u64
147    } else {
148        0
149    };
150
151    OverviewResult {
152        total_sessions: sessions.len(),
153        total_turns,
154        total_agent_turns,
155        tokens_by_model,
156        cost_by_model,
157        total_cost,
158        hourly_distribution,
159        quality,
160        subscription_value,
161        weekday_hour_matrix,
162        tool_counts,
163        cost_by_category,
164        session_summaries,
165        total_output_tokens,
166        total_context_tokens,
167        avg_cache_hit_rate,
168        cache_savings,
169        output_ratio,
170        cost_per_turn,
171        tokens_per_output_turn,
172    }
173}
174
175#[allow(clippy::too_many_arguments)]
176fn process_turn(
177    turn: &crate::data::models::ValidatedTurn,
178    calc: &PricingCalculator,
179    tokens_by_model: &mut HashMap<String, AggregatedTokens>,
180    cost_by_model: &mut HashMap<String, f64>,
181    total_cost: &mut f64,
182    hourly_distribution: &mut [usize; 24],
183    weekday_hour_matrix: &mut [[usize; 24]; 7],
184    cost_by_category: &mut CostByCategory,
185) {
186    // Aggregate tokens by model
187    tokens_by_model
188        .entry(turn.model.clone())
189        .or_default()
190        .add_usage(&turn.usage);
191
192    // Calculate cost
193    let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
194    *cost_by_model.entry(turn.model.clone()).or_insert(0.0) += cost.total;
195    *total_cost += cost.total;
196
197    // Accumulate cost by category
198    cost_by_category.input_cost += cost.input_cost;
199    cost_by_category.output_cost += cost.output_cost;
200    cost_by_category.cache_write_5m_cost += cost.cache_write_5m_cost;
201    cost_by_category.cache_write_1h_cost += cost.cache_write_1h_cost;
202    cost_by_category.cache_read_cost += cost.cache_read_cost;
203
204    // Hourly distribution (local timezone)
205    let local_ts = turn.timestamp.with_timezone(&Local);
206    let hour = local_ts.hour() as usize;
207    hourly_distribution[hour] += 1;
208
209    // Weekday-hour matrix (local timezone)
210    let weekday = local_ts.weekday().num_days_from_monday() as usize; // 0=Mon..6=Sun
211    weekday_hour_matrix[weekday][hour] += 1;
212}
213
214/// Build a SessionSummary for a single session.
215fn build_session_summary(session: &SessionData, calc: &PricingCalculator) -> SessionSummary {
216    let session_id = if session.session_id.len() > 8 {
217        session.session_id[..8].to_string()
218    } else {
219        session.session_id.clone()
220    };
221
222    let project_display_name = session
223        .project
224        .as_deref()
225        .map(crate::analysis::project::project_display_name)
226        .unwrap_or_else(|| "(unknown)".to_string());
227
228    let all_turns = session.all_responses();
229    let turn_count = all_turns.len();
230
231    // Duration
232    let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
233        (Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
234        _ => 0.0,
235    };
236
237    // Model frequency, output/context tokens, max_context, cache stats, compaction, cost
238    let mut model_counts: HashMap<&str, usize> = HashMap::new();
239    let mut output_tokens: u64 = 0;
240    let mut context_tokens: u64 = 0;
241    let mut max_context: u64 = 0;
242    let mut total_cache_read: u64 = 0;
243    let mut total_context: u64 = 0;
244    let mut total_5m: u64 = 0;
245    let mut total_1h: u64 = 0;
246    let mut compaction_count: usize = 0;
247    let mut agent_turn_count: usize = 0;
248    let mut tool_use_count: usize = 0;
249    let mut total_cost: f64 = 0.0;
250    let mut prev_context_size: Option<u64> = None;
251    let mut tool_map: HashMap<String, usize> = HashMap::new();
252
253    for turn in &all_turns {
254        *model_counts.entry(&turn.model).or_insert(0) += 1;
255
256        let input = turn.usage.input_tokens.unwrap_or(0);
257        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
258        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
259        let out = turn.usage.output_tokens.unwrap_or(0);
260
261        output_tokens += out;
262        let ctx = input + cache_create + cache_read;
263        context_tokens += ctx;
264        total_context += ctx;
265        total_cache_read += cache_read;
266
267        if ctx > max_context {
268            max_context = ctx;
269        }
270
271        // TTL breakdown
272        if let Some(ref detail) = turn.usage.cache_creation {
273            total_5m += detail.ephemeral_5m_input_tokens.unwrap_or(0);
274            total_1h += detail.ephemeral_1h_input_tokens.unwrap_or(0);
275        }
276
277        // Compaction detection
278        if let Some(prev) = prev_context_size {
279            if prev > 0 && (ctx as f64) < (prev as f64 * 0.9) {
280                compaction_count += 1;
281            }
282        }
283        prev_context_size = Some(ctx);
284
285        // Agent turns
286        if turn.is_agent {
287            agent_turn_count += 1;
288        }
289
290        // Tool use count
291        if turn.stop_reason.as_deref() == Some("tool_use") {
292            tool_use_count += 1;
293        }
294        for name in &turn.tool_names {
295            *tool_map.entry(name.clone()).or_insert(0) += 1;
296        }
297
298        // Cost
299        let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
300        total_cost += cost.total;
301    }
302
303    // Primary model
304    let model = model_counts
305        .into_iter()
306        .max_by_key(|(_, count)| *count)
307        .map(|(m, _)| m.to_string())
308        .unwrap_or_default();
309
310    // Cache hit rate
311    let cache_hit_rate = if total_context > 0 {
312        (total_cache_read as f64 / total_context as f64) * 100.0
313    } else {
314        0.0
315    };
316
317    // Cache write 5m percentage
318    let total_cache_write = total_5m + total_1h;
319    let cache_write_5m_pct = if total_cache_write > 0 {
320        (total_5m as f64 / total_cache_write as f64) * 100.0
321    } else {
322        0.0
323    };
324
325    let output_ratio = if context_tokens > 0 {
326        output_tokens as f64 / context_tokens as f64 * 100.0
327    } else {
328        0.0
329    };
330    let cost_per_turn = if turn_count > 0 {
331        total_cost / turn_count as f64
332    } else {
333        0.0
334    };
335
336    SessionSummary {
337        session_id,
338        project_display_name,
339        first_timestamp: session.first_timestamp,
340        duration_minutes,
341        model,
342        turn_count,
343        agent_turn_count,
344        output_tokens,
345        context_tokens,
346        max_context,
347        cache_hit_rate,
348        cache_write_5m_pct,
349        compaction_count,
350        cost: total_cost,
351        tool_use_count,
352        top_tools: {
353            let mut tools: Vec<(String, usize)> = tool_map.into_iter().collect();
354            tools.sort_by(|a, b| b.1.cmp(&a.1));
355            tools.truncate(5);
356            tools
357        },
358        turn_details: None,
359        output_ratio,
360        cost_per_turn,
361    }
362}