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::{PriceSource, PricingCalculator};
7
8use super::{
9    AggregatedTokens, CacheSavings, CostByCategory, OverviewResult, PricingWarning, SessionSummary,
10    SubscriptionValue,
11};
12
13/// Accumulator for one unknown model encountered during overview aggregation.
14#[derive(Default)]
15struct FallbackAccum {
16    fallback_to: String,
17    turn_count: u64,
18    fallback_cost: f64,
19}
20
21pub fn analyze_overview(
22    sessions: &[SessionData],
23    quality: GlobalDataQuality,
24    calc: &PricingCalculator,
25    subscription_price: Option<f64>,
26) -> OverviewResult {
27    let mut tokens_by_model: HashMap<String, AggregatedTokens> = HashMap::new();
28    let mut cost_by_model: HashMap<String, f64> = HashMap::new();
29    let mut total_cost = 0.0;
30    let mut hourly_distribution = [0usize; 24];
31    let mut weekday_hour_matrix = [[0usize; 24]; 7];
32    let mut total_turns = 0usize;
33    let mut total_agent_turns = 0usize;
34    let mut cost_by_category = CostByCategory::default();
35    let mut tool_count_map: HashMap<String, usize> = HashMap::new();
36    // Collect fallback occurrences keyed by the *requested* (unknown) model.
37    let mut fallback_map: HashMap<String, FallbackAccum> = HashMap::new();
38
39    for session in sessions {
40        for turn in session.all_responses() {
41            process_turn(
42                turn,
43                calc,
44                &mut tokens_by_model,
45                &mut cost_by_model,
46                &mut total_cost,
47                &mut hourly_distribution,
48                &mut weekday_hour_matrix,
49                &mut cost_by_category,
50                &mut fallback_map,
51            );
52            total_turns += 1;
53            if turn.is_agent {
54                total_agent_turns += 1;
55            }
56
57            // Aggregate tool usage
58            for name in &turn.tool_names {
59                *tool_count_map.entry(name.clone()).or_insert(0) += 1;
60            }
61        }
62    }
63
64    let mut tool_counts: Vec<(String, usize)> = tool_count_map.into_iter().collect();
65    tool_counts.sort_by_key(|b| std::cmp::Reverse(b.1));
66
67    // Compute totals from tokens_by_model
68    let mut total_output_tokens: u64 = 0;
69    let mut total_context_tokens: u64 = 0;
70    for agg in tokens_by_model.values() {
71        total_output_tokens += agg.output_tokens;
72        total_context_tokens += agg.context_tokens();
73    }
74
75    // Average cache hit rate
76    let total_cache_read: u64 = tokens_by_model.values().map(|a| a.cache_read_tokens).sum();
77    let avg_cache_hit_rate = if total_context_tokens > 0 {
78        (total_cache_read as f64 / total_context_tokens as f64) * 100.0
79    } else {
80        0.0
81    };
82
83    // Build session summaries
84    let session_summaries: Vec<SessionSummary> = sessions
85        .iter()
86        .map(|s| build_session_summary(s, calc))
87        .collect();
88
89    // Note: turn_details intentionally left as None for overview.
90    // Individual session details are only generated for the session subcommand.
91    // This keeps the HTML report lightweight.
92
93    // ── Cache savings calculation ───────────────────────────────────────────
94    // Savings = what cache_read tokens would cost at base_input rate minus actual cache_read cost
95    let cache_savings = {
96        let mut total_saved = 0.0f64;
97        let mut without_cache = 0.0f64;
98        let mut with_cache = 0.0f64;
99        let mut by_model: Vec<(String, f64)> = Vec::new();
100
101        for (model, tokens) in &tokens_by_model {
102            if let Some((price, _)) = calc.get_price(model) {
103                let cache_read_mtok = tokens.cache_read_tokens as f64 / 1_000_000.0;
104                let hypothetical = cache_read_mtok * price.base_input;
105                let actual = cache_read_mtok * price.cache_read;
106                let saved = hypothetical - actual;
107                without_cache += hypothetical;
108                with_cache += actual;
109                total_saved += saved;
110                if saved > 0.01 {
111                    by_model.push((model.clone(), saved));
112                }
113            }
114        }
115        by_model.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
116
117        let savings_pct = if without_cache > 0.0 {
118            total_saved / without_cache * 100.0
119        } else {
120            0.0
121        };
122
123        CacheSavings {
124            total_saved,
125            without_cache_cost: without_cache,
126            with_cache_cost: with_cache,
127            savings_pct,
128            by_model,
129        }
130    };
131
132    let subscription_value = subscription_price.map(|monthly_price| {
133        let value_multiplier = if total_cost > 0.0 {
134            total_cost / monthly_price
135        } else {
136            0.0
137        };
138        SubscriptionValue {
139            monthly_price,
140            api_equivalent: total_cost,
141            value_multiplier,
142        }
143    });
144
145    // Efficiency metrics
146    let output_ratio = if total_context_tokens > 0 {
147        total_output_tokens as f64 / total_context_tokens as f64 * 100.0
148    } else {
149        0.0
150    };
151    let cost_per_turn = if total_turns > 0 {
152        total_cost / total_turns as f64
153    } else {
154        0.0
155    };
156    let tokens_per_output_turn = if total_turns > 0 {
157        total_output_tokens / total_turns as u64
158    } else {
159        0
160    };
161
162    // Build sorted, deterministic pricing warnings: cost desc, then model name asc.
163    let mut pricing_warnings: Vec<PricingWarning> = fallback_map
164        .into_iter()
165        .map(|(unknown_model, acc)| PricingWarning {
166            unknown_model,
167            fallback_to: acc.fallback_to,
168            turn_count: acc.turn_count,
169            fallback_cost: acc.fallback_cost,
170        })
171        .collect();
172    pricing_warnings.sort_by(|a, b| {
173        b.fallback_cost
174            .partial_cmp(&a.fallback_cost)
175            .unwrap_or(std::cmp::Ordering::Equal)
176            .then_with(|| a.unknown_model.cmp(&b.unknown_model))
177    });
178
179    OverviewResult {
180        total_sessions: sessions.len(),
181        total_turns,
182        total_agent_turns,
183        tokens_by_model,
184        cost_by_model,
185        total_cost,
186        hourly_distribution,
187        quality,
188        subscription_value,
189        weekday_hour_matrix,
190        tool_counts,
191        cost_by_category,
192        session_summaries,
193        total_output_tokens,
194        total_context_tokens,
195        avg_cache_hit_rate,
196        cache_savings,
197        output_ratio,
198        cost_per_turn,
199        tokens_per_output_turn,
200        pricing_warnings,
201    }
202}
203
204#[allow(clippy::too_many_arguments)]
205fn process_turn(
206    turn: &crate::data::models::ValidatedTurn,
207    calc: &PricingCalculator,
208    tokens_by_model: &mut HashMap<String, AggregatedTokens>,
209    cost_by_model: &mut HashMap<String, f64>,
210    total_cost: &mut f64,
211    hourly_distribution: &mut [usize; 24],
212    weekday_hour_matrix: &mut [[usize; 24]; 7],
213    cost_by_category: &mut CostByCategory,
214    fallback_map: &mut HashMap<String, FallbackAccum>,
215) {
216    // Aggregate tokens by model
217    tokens_by_model
218        .entry(turn.model.clone())
219        .or_default()
220        .add_usage(&turn.usage);
221
222    // Calculate cost
223    let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
224    *cost_by_model.entry(turn.model.clone()).or_insert(0.0) += cost.total;
225    *total_cost += cost.total;
226
227    // Accumulate cost by category
228    cost_by_category.input_cost += cost.input_cost;
229    cost_by_category.output_cost += cost.output_cost;
230    cost_by_category.cache_write_5m_cost += cost.cache_write_5m_cost;
231    cost_by_category.cache_write_1h_cost += cost.cache_write_1h_cost;
232    cost_by_category.cache_read_cost += cost.cache_read_cost;
233
234    // Track unknown-model fallbacks so the user can be warned that those
235    // dollars are estimates. Keyed by the model name as it actually appeared.
236    if let PriceSource::Fallback {
237        ref requested,
238        ref fallback_to,
239    } = cost.price_source
240    {
241        let entry = fallback_map.entry(requested.clone()).or_default();
242        if entry.fallback_to.is_empty() {
243            entry.fallback_to = fallback_to.clone();
244        }
245        entry.turn_count += 1;
246        entry.fallback_cost += cost.total;
247    }
248
249    // Hourly distribution (local timezone)
250    let local_ts = turn.timestamp.with_timezone(&Local);
251    let hour = local_ts.hour() as usize;
252    hourly_distribution[hour] += 1;
253
254    // Weekday-hour matrix (local timezone)
255    let weekday = local_ts.weekday().num_days_from_monday() as usize; // 0=Mon..6=Sun
256    weekday_hour_matrix[weekday][hour] += 1;
257}
258
259/// Build a SessionSummary for a single session.
260fn build_session_summary(session: &SessionData, calc: &PricingCalculator) -> SessionSummary {
261    let session_id = if session.session_id.len() > 8 {
262        session.session_id[..8].to_string()
263    } else {
264        session.session_id.clone()
265    };
266
267    let project_display_name = session
268        .project
269        .as_deref()
270        .map(crate::analysis::project::project_display_name)
271        .unwrap_or_else(|| "(unknown)".to_string());
272
273    let all_turns = session.all_responses();
274    let turn_count = all_turns.len();
275
276    // Duration
277    let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
278        (Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
279        _ => 0.0,
280    };
281
282    // Model frequency, output/context tokens, max_context, cache stats, compaction, cost
283    let mut model_counts: HashMap<&str, usize> = HashMap::new();
284    let mut output_tokens: u64 = 0;
285    let mut context_tokens: u64 = 0;
286    let mut max_context: u64 = 0;
287    let mut total_cache_read: u64 = 0;
288    let mut total_context: u64 = 0;
289    let mut total_5m: u64 = 0;
290    let mut total_1h: u64 = 0;
291    let mut compaction_count: usize = 0;
292    let mut agent_turn_count: usize = 0;
293    let mut tool_use_count: usize = 0;
294    let mut total_cost: f64 = 0.0;
295    let mut prev_context_size: Option<u64> = None;
296    let mut tool_map: HashMap<String, usize> = HashMap::new();
297
298    for turn in &all_turns {
299        *model_counts.entry(&turn.model).or_insert(0) += 1;
300
301        let input = turn.usage.input_tokens.unwrap_or(0);
302        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
303        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
304        let out = turn.usage.output_tokens.unwrap_or(0);
305
306        output_tokens += out;
307        let ctx = input + cache_create + cache_read;
308        context_tokens += ctx;
309        total_context += ctx;
310        total_cache_read += cache_read;
311
312        if ctx > max_context {
313            max_context = ctx;
314        }
315
316        // TTL breakdown
317        if let Some(ref detail) = turn.usage.cache_creation {
318            total_5m += detail.ephemeral_5m_input_tokens.unwrap_or(0);
319            total_1h += detail.ephemeral_1h_input_tokens.unwrap_or(0);
320        }
321
322        // Compaction detection
323        if let Some(prev) = prev_context_size {
324            if prev > 0 && (ctx as f64) < (prev as f64 * 0.9) {
325                compaction_count += 1;
326            }
327        }
328        prev_context_size = Some(ctx);
329
330        // Agent turns
331        if turn.is_agent {
332            agent_turn_count += 1;
333        }
334
335        // Tool use count
336        if turn.stop_reason.as_deref() == Some("tool_use") {
337            tool_use_count += 1;
338        }
339        for name in &turn.tool_names {
340            *tool_map.entry(name.clone()).or_insert(0) += 1;
341        }
342
343        // Cost
344        let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
345        total_cost += cost.total;
346    }
347
348    // Primary model
349    let model = model_counts
350        .into_iter()
351        .max_by_key(|(_, count)| *count)
352        .map(|(m, _)| m.to_string())
353        .unwrap_or_default();
354
355    // Cache hit rate
356    let cache_hit_rate = if total_context > 0 {
357        (total_cache_read as f64 / total_context as f64) * 100.0
358    } else {
359        0.0
360    };
361
362    // Cache write 5m percentage
363    let total_cache_write = total_5m + total_1h;
364    let cache_write_5m_pct = if total_cache_write > 0 {
365        (total_5m as f64 / total_cache_write as f64) * 100.0
366    } else {
367        0.0
368    };
369
370    let output_ratio = if context_tokens > 0 {
371        output_tokens as f64 / context_tokens as f64 * 100.0
372    } else {
373        0.0
374    };
375    let cost_per_turn = if turn_count > 0 {
376        total_cost / turn_count as f64
377    } else {
378        0.0
379    };
380
381    SessionSummary {
382        session_id,
383        project_display_name,
384        first_timestamp: session.first_timestamp,
385        duration_minutes,
386        model,
387        turn_count,
388        agent_turn_count,
389        output_tokens,
390        context_tokens,
391        max_context,
392        cache_hit_rate,
393        cache_write_5m_pct,
394        compaction_count,
395        cost: total_cost,
396        tool_use_count,
397        top_tools: {
398            let mut tools: Vec<(String, usize)> = tool_map.into_iter().collect();
399            tools.sort_by_key(|b| std::cmp::Reverse(b.1));
400            tools.truncate(5);
401            tools
402        },
403        turn_details: None,
404        output_ratio,
405        cost_per_turn,
406        is_orphan: session.is_orphan,
407    }
408}
409
410// ─── Tests ──────────────────────────────────────────────────────────────────
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::data::models::{
416        DataQuality, SessionData, SessionMetadata, TokenUsage, ValidatedTurn,
417    };
418    use chrono::{TimeZone, Utc};
419
420    fn make_turn(model: &str, input: u64, output: u64) -> ValidatedTurn {
421        ValidatedTurn {
422            uuid: format!("uuid-{}-{}", model, input),
423            request_id: None,
424            timestamp: Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap(),
425            model: model.to_string(),
426            usage: TokenUsage {
427                input_tokens: Some(input),
428                output_tokens: Some(output),
429                cache_creation_input_tokens: Some(0),
430                cache_read_input_tokens: Some(0),
431                cache_creation: None,
432                server_tool_use: None,
433                service_tier: None,
434                speed: None,
435                inference_geo: None,
436            },
437            stop_reason: Some("end_turn".to_string()),
438            content_types: vec!["text".to_string()],
439            is_agent: false,
440            agent_id: None,
441            user_text: None,
442            assistant_text: None,
443            tool_names: vec![],
444            service_tier: None,
445            speed: None,
446            inference_geo: None,
447            tool_error_count: 0,
448            git_branch: None,
449            attribution_plugin: None,
450            attribution_skill: None,
451        }
452    }
453
454    fn make_session(turns: Vec<ValidatedTurn>) -> SessionData {
455        SessionData {
456            session_id: "test-session".to_string(),
457            project: Some("test-project".to_string()),
458            turns,
459            subagents: vec![],
460            plugins: vec![],
461            skills: vec![],
462            hooks: vec![],
463            first_timestamp: Some(Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap()),
464            last_timestamp: Some(Utc.with_ymd_and_hms(2026, 5, 1, 13, 0, 0).unwrap()),
465            version: None,
466            quality: DataQuality::default(),
467            metadata: SessionMetadata::default(),
468            is_orphan: false,
469        }
470    }
471
472    /// Mix one known model (claude-opus-4-6) with two unknown models that
473    /// each take the LATEST_FALLBACK_MODEL fallback. Aggregation must:
474    ///   1. produce exactly one PricingWarning per distinct unknown model
475    ///   2. sum turn_count and fallback_cost per unknown model
476    ///   3. leave the known model out of the warnings list
477    ///   4. sort by cost desc (tie-broken by model name asc)
478    #[test]
479    fn pricing_warnings_aggregated_across_session() {
480        let calc = PricingCalculator::new();
481        let session = make_session(vec![
482            make_turn("claude-opus-4-6", 1_000_000, 1_000_000), // known
483            make_turn("claude-future-x-1", 1_000_000, 1_000_000), // unknown -> fallback
484            make_turn("claude-future-x-1", 500_000, 500_000),   // unknown, same model
485            make_turn("claude-future-y-2", 2_000_000, 2_000_000), // unknown, distinct
486        ]);
487
488        let result = analyze_overview(&[session], GlobalDataQuality::default(), &calc, None);
489
490        assert_eq!(
491            result.pricing_warnings.len(),
492            2,
493            "expected one warning per distinct unknown model"
494        );
495
496        // future-y-2 has the higher cost (2M+2M vs 1.5M+1.5M) → first by sort.
497        let first = &result.pricing_warnings[0];
498        assert_eq!(first.unknown_model, "claude-future-y-2");
499        assert_eq!(first.turn_count, 1);
500        assert_eq!(first.fallback_to, "claude-opus-4-7");
501        // 2M input * $5 + 2M output * $25 = $60
502        assert!(
503            (first.fallback_cost - 60.0).abs() < 1e-9,
504            "fallback_cost: {}",
505            first.fallback_cost
506        );
507
508        let second = &result.pricing_warnings[1];
509        assert_eq!(second.unknown_model, "claude-future-x-1");
510        assert_eq!(second.turn_count, 2);
511        assert_eq!(second.fallback_to, "claude-opus-4-7");
512        // (1M+0.5M) * $5 input + (1M+0.5M) * $25 output = $7.5 + $37.5 = $45
513        assert!(
514            (second.fallback_cost - 45.0).abs() < 1e-9,
515            "fallback_cost: {}",
516            second.fallback_cost
517        );
518
519        // The known model must not appear in pricing_warnings.
520        assert!(
521            !result
522                .pricing_warnings
523                .iter()
524                .any(|w| w.unknown_model == "claude-opus-4-6"),
525            "known model leaked into pricing_warnings"
526        );
527    }
528}