Skip to main content

cc_token_usage/analysis/
session.rs

1use std::collections::HashMap;
2
3use crate::data::models::SessionData;
4use crate::pricing::calculator::PricingCalculator;
5
6use super::{
7    AgentDetail, AgentSummary, AggregatedTokens, SessionResult, SubagentSummary, TurnCostBreakdown,
8    TurnDetail,
9};
10
11/// Agent metadata loaded from .meta.json files.
12#[derive(Debug, Clone, Default)]
13pub struct AgentMeta {
14    pub agent_type: String,
15    pub description: String,
16}
17
18pub fn analyze_session(
19    session: &SessionData,
20    calc: &PricingCalculator,
21    agent_meta: &std::collections::HashMap<String, AgentMeta>,
22) -> SessionResult {
23    let all_turns = session.all_responses();
24
25    let mut turn_details = Vec::new();
26    let mut total_tokens = AggregatedTokens::default();
27    let mut total_cost = 0.0;
28    let mut stop_reason_counts: HashMap<String, usize> = HashMap::new();
29    let mut agent_summary = AgentSummary::default();
30    let mut model_counts: HashMap<&str, usize> = HashMap::new();
31    let mut max_context: u64 = 0;
32    let mut prev_context_size: Option<u64> = None;
33    let mut agent_acc: HashMap<String, (usize, u64, f64)> = HashMap::new();
34
35    // Phase 1: new accumulators
36    let mut tool_error_count: usize = 0;
37    let mut truncated_count: usize = 0;
38    let mut service_tiers: HashMap<String, usize> = HashMap::new();
39    let mut speeds: HashMap<String, usize> = HashMap::new();
40    let mut inference_geos: HashMap<String, usize> = HashMap::new();
41    let mut git_branches: HashMap<String, usize> = HashMap::new();
42
43    for (i, turn) in all_turns.iter().enumerate() {
44        let input = turn.usage.input_tokens.unwrap_or(0);
45        let output = turn.usage.output_tokens.unwrap_or(0);
46        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
47        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
48
49        // Extract 5m/1h TTL breakdown
50        let (cache_write_5m, cache_write_1h) = if let Some(ref detail) = turn.usage.cache_creation {
51            (
52                detail.ephemeral_5m_input_tokens.unwrap_or(0),
53                detail.ephemeral_1h_input_tokens.unwrap_or(0),
54            )
55        } else {
56            (0, 0)
57        };
58
59        let context_size = input + cache_create + cache_read;
60        let cache_hit_rate = if context_size > 0 {
61            (cache_read as f64 / context_size as f64) * 100.0
62        } else {
63            0.0
64        };
65
66        // Track max context
67        if context_size > max_context {
68            max_context = context_size;
69        }
70
71        // Compaction detection and context delta
72        let is_compaction = match prev_context_size {
73            Some(prev) => prev > 0 && (context_size as f64) < (prev as f64 * 0.9),
74            None => false,
75        };
76        let context_delta = match prev_context_size {
77            Some(prev) => context_size as i64 - prev as i64,
78            None => 0,
79        };
80        prev_context_size = Some(context_size);
81
82        let pricing_cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
83
84        let cost_breakdown = TurnCostBreakdown {
85            input_cost: pricing_cost.input_cost,
86            output_cost: pricing_cost.output_cost,
87            cache_write_5m_cost: pricing_cost.cache_write_5m_cost,
88            cache_write_1h_cost: pricing_cost.cache_write_1h_cost,
89            cache_read_cost: pricing_cost.cache_read_cost,
90            total: pricing_cost.total,
91        };
92
93        // Track stop reasons
94        if let Some(ref reason) = turn.stop_reason {
95            *stop_reason_counts.entry(reason.clone()).or_insert(0) += 1;
96            if reason == "max_tokens" {
97                truncated_count += 1;
98            }
99        }
100
101        // Phase 1: accumulate new metrics
102        tool_error_count += turn.tool_error_count;
103        if let Some(ref tier) = turn.service_tier {
104            if !tier.is_empty() {
105                *service_tiers.entry(tier.clone()).or_insert(0) += 1;
106            }
107        }
108        if let Some(ref spd) = turn.speed {
109            if !spd.is_empty() {
110                *speeds.entry(spd.clone()).or_insert(0) += 1;
111            }
112        }
113        if let Some(ref geo) = turn.inference_geo {
114            if !geo.is_empty() {
115                *inference_geos.entry(geo.clone()).or_insert(0) += 1;
116            }
117        }
118        if let Some(ref branch) = turn.git_branch {
119            if !branch.is_empty() {
120                *git_branches.entry(branch.clone()).or_insert(0) += 1;
121            }
122        }
123
124        // Aggregate tokens
125        total_tokens.add_usage(&turn.usage);
126        total_cost += pricing_cost.total;
127
128        // Model frequency
129        *model_counts.entry(&turn.model).or_insert(0) += 1;
130
131        // Agent summary
132        let is_agent = turn.is_agent;
133        if is_agent {
134            agent_summary.total_agent_turns += 1;
135            agent_summary.agent_output_tokens += output;
136            agent_summary.agent_cost += pricing_cost.total;
137
138            // Per-agent accumulation
139            let aid = turn.agent_id.clone().unwrap_or_default();
140            if !aid.is_empty() {
141                let entry = agent_acc.entry(aid).or_insert((0usize, 0u64, 0.0f64));
142                entry.0 += 1;
143                entry.1 += output;
144                entry.2 += pricing_cost.total;
145            }
146        }
147
148        turn_details.push(TurnDetail {
149            turn_number: i + 1,
150            timestamp: turn.timestamp,
151            model: turn.model.clone(),
152            input_tokens: input,
153            output_tokens: output,
154            cache_write_5m_tokens: cache_write_5m,
155            cache_write_1h_tokens: cache_write_1h,
156            cache_read_tokens: cache_read,
157            context_size,
158            cache_hit_rate,
159            cost: pricing_cost.total,
160            cost_breakdown,
161            stop_reason: turn.stop_reason.clone(),
162            is_agent,
163            is_compaction,
164            context_delta,
165            user_text: turn.user_text.clone(),
166            assistant_text: turn.assistant_text.clone(),
167            tool_names: turn.tool_names.clone(),
168        });
169    }
170
171    // Duration
172    let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
173        (Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
174        _ => 0.0,
175    };
176
177    // Compaction count
178    let compaction_count = turn_details.iter().filter(|t| t.is_compaction).count();
179
180    // Cache write percentages from total tokens
181    let total_5m = total_tokens.cache_write_5m_tokens;
182    let total_1h = total_tokens.cache_write_1h_tokens;
183    let total_cache_write = total_5m + total_1h;
184    let cache_write_5m_pct = if total_cache_write > 0 {
185        (total_5m as f64 / total_cache_write as f64) * 100.0
186    } else {
187        0.0
188    };
189    let cache_write_1h_pct = if total_cache_write > 0 {
190        (total_1h as f64 / total_cache_write as f64) * 100.0
191    } else {
192        0.0
193    };
194
195    // Per-agent details
196    let mut agents: Vec<AgentDetail> = agent_acc
197        .into_iter()
198        .map(|(aid, (turns, output, cost))| {
199            let meta = agent_meta.get(&aid);
200            AgentDetail {
201                agent_id: aid,
202                agent_type: meta.map_or_else(|| "unknown".into(), |m| m.agent_type.clone()),
203                description: meta.map_or_else(|| "".into(), |m| m.description.clone()),
204                turns,
205                output_tokens: output,
206                cost,
207            }
208        })
209        .collect();
210    agents.sort_by(|a, b| {
211        b.cost
212            .partial_cmp(&a.cost)
213            .unwrap_or(std::cmp::Ordering::Equal)
214    });
215    agent_summary.agents = agents;
216
217    // Primary model
218    let model = model_counts
219        .into_iter()
220        .max_by_key(|(_, count)| *count)
221        .map(|(m, _)| m.to_string())
222        .unwrap_or_default();
223
224    // Autonomy ratio
225    let total_turn_count = session.total_turn_count();
226    let user_prompt_count = session.metadata.user_prompt_count;
227    let autonomy_ratio = if user_prompt_count > 0 {
228        total_turn_count as f64 / user_prompt_count as f64
229    } else {
230        0.0
231    };
232
233    SessionResult {
234        session_id: session.session_id.clone(),
235        project: session
236            .project
237            .clone()
238            .unwrap_or_else(|| "(unknown)".to_string()),
239        turn_details,
240        agent_summary,
241        total_tokens,
242        total_cost,
243        stop_reason_counts,
244        duration_minutes,
245        max_context,
246        compaction_count,
247        cache_write_5m_pct,
248        cache_write_1h_pct,
249        model,
250        // Phase 1: metadata
251        title: session.metadata.title.clone(),
252        tags: session.metadata.tags.clone(),
253        mode: session.metadata.mode.clone(),
254        pr_links: session.metadata.pr_links.clone(),
255        // Autonomy
256        user_prompt_count,
257        autonomy_ratio,
258        // Errors
259        api_error_count: session.metadata.api_error_count,
260        tool_error_count,
261        truncated_count,
262        // Speculation
263        speculation_accepts: session.metadata.speculation_accepts,
264        speculation_time_saved_ms: session.metadata.speculation_time_saved_ms,
265        // Service info
266        service_tiers,
267        speeds,
268        inference_geos,
269        // Git
270        git_branches,
271        // Context Collapse
272        collapse_count: session.metadata.collapse_commits.len(),
273        collapse_summaries: session
274            .metadata
275            .collapse_commits
276            .iter()
277            .map(|c| c.summary.clone())
278            .collect(),
279        collapse_avg_risk: session
280            .metadata
281            .collapse_snapshot
282            .as_ref()
283            .map_or(0.0, |s| s.avg_risk),
284        collapse_max_risk: session
285            .metadata
286            .collapse_snapshot
287            .as_ref()
288            .map_or(0.0, |s| s.max_risk),
289        // Attribution
290        attribution: session.metadata.attribution.clone(),
291        // ── Phase 2: per-session capability inventory ──
292        subagents: build_subagent_summaries(session, calc),
293        plugins: session.plugins.clone(),
294        skills: session.skills.clone(),
295        hooks: session.hooks.clone(),
296        subagent_types: session.subagent_type_aggregates(calc),
297        is_orphan: session.is_orphan,
298    }
299}
300
301/// Build one `SubagentSummary` per `Subagent`, costed via the pricing
302/// calculator. Sort order: by cost descending (most expensive first), matching
303/// the existing `agent_summary.agents` ordering convention.
304fn build_subagent_summaries(
305    session: &SessionData,
306    calc: &PricingCalculator,
307) -> Vec<SubagentSummary> {
308    let mut out: Vec<SubagentSummary> = session
309        .subagents
310        .iter()
311        .map(|sa| {
312            let mut output_tokens: u64 = 0;
313            let mut cost = 0.0f64;
314            for t in &sa.turns {
315                output_tokens += t.usage.output_tokens.unwrap_or(0);
316                cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
317            }
318            SubagentSummary {
319                agent_id: sa.agent_id.clone(),
320                agent_type: sa.agent_type.clone(),
321                description: sa.description.clone(),
322                turns: sa.turns.len(),
323                output_tokens,
324                cost,
325            }
326        })
327        .collect();
328    out.sort_by(|a, b| {
329        b.cost
330            .partial_cmp(&a.cost)
331            .unwrap_or(std::cmp::Ordering::Equal)
332    });
333    out
334}