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