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#[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 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 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 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 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 let session_summaries: Vec<SessionSummary> = sessions
85 .iter()
86 .map(|s| build_session_summary(s, calc))
87 .collect();
88
89 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 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 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 tokens_by_model
218 .entry(turn.model.clone())
219 .or_default()
220 .add_usage(&turn.usage);
221
222 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 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 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 let local_ts = turn.timestamp.with_timezone(&Local);
251 let hour = local_ts.hour() as usize;
252 hourly_distribution[hour] += 1;
253
254 let weekday = local_ts.weekday().num_days_from_monday() as usize; weekday_hour_matrix[weekday][hour] += 1;
257}
258
259fn 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 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 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 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 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 if turn.is_agent {
332 agent_turn_count += 1;
333 }
334
335 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 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
345 total_cost += cost.total;
346 }
347
348 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 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 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#[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 #[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), make_turn("claude-future-x-1", 1_000_000, 1_000_000), make_turn("claude-future-x-1", 500_000, 500_000), make_turn("claude-future-y-2", 2_000_000, 2_000_000), ]);
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 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 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 assert!(
514 (second.fallback_cost - 45.0).abs() < 1e-9,
515 "fallback_cost: {}",
516 second.fallback_cost
517 );
518
519 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}