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 title: session.metadata.title.clone(),
385 first_timestamp: session.first_timestamp,
386 duration_minutes,
387 model,
388 turn_count,
389 agent_turn_count,
390 output_tokens,
391 context_tokens,
392 max_context,
393 cache_hit_rate,
394 cache_write_5m_pct,
395 compaction_count,
396 cost: total_cost,
397 tool_use_count,
398 top_tools: {
399 let mut tools: Vec<(String, usize)> = tool_map.into_iter().collect();
400 tools.sort_by_key(|b| std::cmp::Reverse(b.1));
401 tools.truncate(5);
402 tools
403 },
404 turn_details: None,
405 output_ratio,
406 cost_per_turn,
407 is_orphan: session.is_orphan,
408 }
409}
410
411#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::data::models::{
417 DataQuality, SessionData, SessionMetadata, TokenUsage, ValidatedTurn,
418 };
419 use chrono::{TimeZone, Utc};
420
421 fn make_turn(model: &str, input: u64, output: u64) -> ValidatedTurn {
422 ValidatedTurn {
423 uuid: format!("uuid-{}-{}", model, input),
424 request_id: None,
425 timestamp: Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap(),
426 model: model.to_string(),
427 usage: TokenUsage {
428 input_tokens: Some(input),
429 output_tokens: Some(output),
430 cache_creation_input_tokens: Some(0),
431 cache_read_input_tokens: Some(0),
432 cache_creation: None,
433 server_tool_use: None,
434 service_tier: None,
435 speed: None,
436 inference_geo: None,
437 },
438 stop_reason: Some("end_turn".to_string()),
439 content_types: vec!["text".to_string()],
440 is_agent: false,
441 agent_id: None,
442 user_text: None,
443 assistant_text: None,
444 tool_names: vec![],
445 service_tier: None,
446 speed: None,
447 inference_geo: None,
448 tool_error_count: 0,
449 git_branch: None,
450 attribution_plugin: None,
451 attribution_skill: None,
452 }
453 }
454
455 fn make_session(turns: Vec<ValidatedTurn>) -> SessionData {
456 SessionData {
457 session_id: "test-session".to_string(),
458 project: Some("test-project".to_string()),
459 turns,
460 subagents: vec![],
461 plugins: vec![],
462 skills: vec![],
463 hooks: vec![],
464 first_timestamp: Some(Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap()),
465 last_timestamp: Some(Utc.with_ymd_and_hms(2026, 5, 1, 13, 0, 0).unwrap()),
466 version: None,
467 quality: DataQuality::default(),
468 metadata: SessionMetadata::default(),
469 is_orphan: false,
470 }
471 }
472
473 #[test]
480 fn pricing_warnings_aggregated_across_session() {
481 let calc = PricingCalculator::new();
482 let session = make_session(vec![
483 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), ]);
488
489 let result = analyze_overview(&[session], GlobalDataQuality::default(), &calc, None);
490
491 assert_eq!(
492 result.pricing_warnings.len(),
493 2,
494 "expected one warning per distinct unknown model"
495 );
496
497 let first = &result.pricing_warnings[0];
499 assert_eq!(first.unknown_model, "claude-future-y-2");
500 assert_eq!(first.turn_count, 1);
501 assert_eq!(
502 first.fallback_to,
503 crate::pricing::calculator::LATEST_FALLBACK_MODEL
504 );
505 assert!(
507 (first.fallback_cost - 60.0).abs() < 1e-9,
508 "fallback_cost: {}",
509 first.fallback_cost
510 );
511
512 let second = &result.pricing_warnings[1];
513 assert_eq!(second.unknown_model, "claude-future-x-1");
514 assert_eq!(second.turn_count, 2);
515 assert_eq!(
516 second.fallback_to,
517 crate::pricing::calculator::LATEST_FALLBACK_MODEL
518 );
519 assert!(
521 (second.fallback_cost - 45.0).abs() < 1e-9,
522 "fallback_cost: {}",
523 second.fallback_cost
524 );
525
526 assert!(
528 !result
529 .pricing_warnings
530 .iter()
531 .any(|w| w.unknown_model == "claude-opus-4-6"),
532 "known model leaked into pricing_warnings"
533 );
534 }
535}