Skip to main content

cc_token_usage/output/
json.rs

1use std::collections::HashMap;
2
3use chrono::Datelike;
4use serde::Serialize;
5
6use crate::analysis::project::project_display_name;
7use crate::analysis::wrapped::WrappedResult;
8use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
9use crate::data::models::SessionData;
10use crate::pricing::calculator::PricingCalculator;
11
12// ─── Overview JSON ──────────────────────────────────────────────────────────
13
14#[derive(Serialize)]
15struct OverviewJson {
16    total_sessions: usize,
17    total_turns: usize,
18    total_agent_turns: usize,
19    total_output_tokens: u64,
20    total_context_tokens: u64,
21    total_cost: f64,
22    avg_cache_hit_rate: f64,
23    // Efficiency
24    output_ratio: f64,
25    cost_per_turn: f64,
26    tokens_per_output_turn: u64,
27    // Cache savings
28    cache_savings: CacheSavingsJson,
29    // Subscription
30    subscription_value: Option<SubscriptionValueJson>,
31    // Cost breakdown
32    cost_by_category: CostByCategoryJson,
33    // Models
34    models: Vec<ModelJson>,
35    // Top tools
36    top_tools: Vec<ToolJson>,
37    // Sessions
38    sessions: Vec<SessionSummaryJson>,
39}
40
41#[derive(Serialize)]
42struct CacheSavingsJson {
43    total_saved: f64,
44    savings_pct: f64,
45}
46
47#[derive(Serialize)]
48struct SubscriptionValueJson {
49    monthly_price: f64,
50    api_equivalent: f64,
51    value_multiplier: f64,
52}
53
54#[derive(Serialize)]
55struct CostByCategoryJson {
56    input_cost: f64,
57    output_cost: f64,
58    cache_write_cost: f64,
59    cache_read_cost: f64,
60}
61
62#[derive(Serialize)]
63struct ModelJson {
64    name: String,
65    output_tokens: u64,
66    turns: usize,
67    cost: f64,
68}
69
70#[derive(Serialize)]
71struct ToolJson {
72    name: String,
73    count: usize,
74}
75
76#[derive(Serialize)]
77struct SessionSummaryJson {
78    session_id: String,
79    project: String,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    first_timestamp: Option<String>,
82    duration_minutes: f64,
83    model: String,
84    turn_count: usize,
85    agent_turn_count: usize,
86    output_tokens: u64,
87    context_tokens: u64,
88    max_context: u64,
89    cache_hit_rate: f64,
90    cost: f64,
91    output_ratio: f64,
92    cost_per_turn: f64,
93}
94
95/// Build the typed `OverviewJson` struct from an `OverviewResult`.
96fn build_overview_json(overview: &OverviewResult) -> OverviewJson {
97    let mut models: Vec<(&String, &crate::analysis::AggregatedTokens)> =
98        overview.tokens_by_model.iter().collect();
99    models.sort_by(|a, b| {
100        let ca = overview.cost_by_model.get(a.0).unwrap_or(&0.0);
101        let cb = overview.cost_by_model.get(b.0).unwrap_or(&0.0);
102        cb.partial_cmp(ca).unwrap_or(std::cmp::Ordering::Equal)
103    });
104
105    let models_json: Vec<ModelJson> = models
106        .iter()
107        .map(|(name, tokens)| ModelJson {
108            name: (*name).clone(),
109            output_tokens: tokens.output_tokens,
110            turns: tokens.turns,
111            cost: *overview.cost_by_model.get(*name).unwrap_or(&0.0),
112        })
113        .collect();
114
115    let top_tools: Vec<ToolJson> = overview
116        .tool_counts
117        .iter()
118        .take(20)
119        .map(|(name, count)| ToolJson {
120            name: name.clone(),
121            count: *count,
122        })
123        .collect();
124
125    let sessions: Vec<SessionSummaryJson> = overview
126        .session_summaries
127        .iter()
128        .map(|s| SessionSummaryJson {
129            session_id: s.session_id.clone(),
130            project: s.project_display_name.clone(),
131            first_timestamp: s.first_timestamp.map(|t| t.to_rfc3339()),
132            duration_minutes: s.duration_minutes,
133            model: s.model.clone(),
134            turn_count: s.turn_count,
135            agent_turn_count: s.agent_turn_count,
136            output_tokens: s.output_tokens,
137            context_tokens: s.context_tokens,
138            max_context: s.max_context,
139            cache_hit_rate: s.cache_hit_rate,
140            cost: s.cost,
141            output_ratio: s.output_ratio,
142            cost_per_turn: s.cost_per_turn,
143        })
144        .collect();
145
146    let cat = &overview.cost_by_category;
147
148    OverviewJson {
149        total_sessions: overview.total_sessions,
150        total_turns: overview.total_turns,
151        total_agent_turns: overview.total_agent_turns,
152        total_output_tokens: overview.total_output_tokens,
153        total_context_tokens: overview.total_context_tokens,
154        total_cost: overview.total_cost,
155        avg_cache_hit_rate: overview.avg_cache_hit_rate,
156        output_ratio: overview.output_ratio,
157        cost_per_turn: overview.cost_per_turn,
158        tokens_per_output_turn: overview.tokens_per_output_turn,
159        cache_savings: CacheSavingsJson {
160            total_saved: overview.cache_savings.total_saved,
161            savings_pct: overview.cache_savings.savings_pct,
162        },
163        subscription_value: overview
164            .subscription_value
165            .as_ref()
166            .map(|sv| SubscriptionValueJson {
167                monthly_price: sv.monthly_price,
168                api_equivalent: sv.api_equivalent,
169                value_multiplier: sv.value_multiplier,
170            }),
171        cost_by_category: CostByCategoryJson {
172            input_cost: cat.input_cost,
173            output_cost: cat.output_cost,
174            cache_write_cost: cat.cache_write_5m_cost + cat.cache_write_1h_cost,
175            cache_read_cost: cat.cache_read_cost,
176        },
177        models: models_json,
178        top_tools,
179        sessions,
180    }
181}
182
183pub fn render_overview_json(overview: &OverviewResult) -> String {
184    let json = build_overview_json(overview);
185    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
186}
187
188// ─── Session JSON ───────────────────────────────────────────────────────────
189
190#[derive(Serialize)]
191struct SessionJson {
192    session_id: String,
193    project: String,
194    model: String,
195    duration_minutes: f64,
196    total_cost: f64,
197    max_context: u64,
198    compaction_count: usize,
199    // Tokens
200    output_tokens: u64,
201    context_tokens: u64,
202    cache_hit_rate: f64,
203    // Agents
204    agent_turns: usize,
205    agent_output_tokens: u64,
206    agent_cost: f64,
207    // Metadata
208    #[serde(skip_serializing_if = "Option::is_none")]
209    title: Option<String>,
210    #[serde(skip_serializing_if = "Vec::is_empty")]
211    tags: Vec<String>,
212    // Turn details
213    turns: Vec<TurnJson>,
214}
215
216#[derive(Serialize)]
217struct TurnJson {
218    turn_number: usize,
219    timestamp: String,
220    model: String,
221    input_tokens: u64,
222    output_tokens: u64,
223    cache_read_tokens: u64,
224    context_size: u64,
225    cache_hit_rate: f64,
226    cost: f64,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    stop_reason: Option<String>,
229    is_agent: bool,
230    is_compaction: bool,
231    #[serde(skip_serializing_if = "Vec::is_empty")]
232    tool_names: Vec<String>,
233}
234
235pub fn render_session_json(result: &SessionResult) -> String {
236    let ctx = result.total_tokens.context_tokens();
237    let cache_hit_rate = if ctx > 0 {
238        result.total_tokens.cache_read_tokens as f64 / ctx as f64 * 100.0
239    } else {
240        0.0
241    };
242
243    let turns: Vec<TurnJson> = result
244        .turn_details
245        .iter()
246        .map(|t| TurnJson {
247            turn_number: t.turn_number,
248            timestamp: t.timestamp.to_rfc3339(),
249            model: t.model.clone(),
250            input_tokens: t.input_tokens,
251            output_tokens: t.output_tokens,
252            cache_read_tokens: t.cache_read_tokens,
253            context_size: t.context_size,
254            cache_hit_rate: t.cache_hit_rate,
255            cost: t.cost,
256            stop_reason: t.stop_reason.clone(),
257            is_agent: t.is_agent,
258            is_compaction: t.is_compaction,
259            tool_names: t.tool_names.clone(),
260        })
261        .collect();
262
263    let json = SessionJson {
264        session_id: result.session_id.clone(),
265        project: result.project.clone(),
266        model: result.model.clone(),
267        duration_minutes: result.duration_minutes,
268        total_cost: result.total_cost,
269        max_context: result.max_context,
270        compaction_count: result.compaction_count,
271        output_tokens: result.total_tokens.output_tokens,
272        context_tokens: ctx,
273        cache_hit_rate,
274        agent_turns: result.agent_summary.total_agent_turns,
275        agent_output_tokens: result.agent_summary.agent_output_tokens,
276        agent_cost: result.agent_summary.agent_cost,
277        title: result.title.clone(),
278        tags: result.tags.clone(),
279        turns,
280    };
281
282    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
283}
284
285// ─── Projects JSON ──────────────────────────────────────────────────────────
286
287#[derive(Serialize)]
288struct ProjectsJson {
289    projects: Vec<ProjectJson>,
290}
291
292#[derive(Serialize)]
293struct ProjectJson {
294    name: String,
295    display_name: String,
296    session_count: usize,
297    total_turns: usize,
298    agent_turns: usize,
299    output_tokens: u64,
300    context_tokens: u64,
301    cost: f64,
302    primary_model: String,
303}
304
305/// Build the typed `ProjectsJson` struct from a `ProjectResult`.
306fn build_projects_json(projects: &ProjectResult) -> ProjectsJson {
307    ProjectsJson {
308        projects: projects
309            .projects
310            .iter()
311            .map(|p| ProjectJson {
312                name: p.name.clone(),
313                display_name: p.display_name.clone(),
314                session_count: p.session_count,
315                total_turns: p.total_turns,
316                agent_turns: p.agent_turns,
317                output_tokens: p.tokens.output_tokens,
318                context_tokens: p.tokens.context_tokens(),
319                cost: p.cost,
320                primary_model: p.primary_model.clone(),
321            })
322            .collect(),
323    }
324}
325
326pub fn render_projects_json(projects: &ProjectResult) -> String {
327    let json = build_projects_json(projects);
328    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
329}
330
331// ─── Trend JSON ─────────────────────────────────────────────────────────────
332
333#[derive(Serialize)]
334struct TrendJson {
335    group_label: String,
336    entries: Vec<TrendEntryJson>,
337}
338
339#[derive(Serialize)]
340struct TrendEntryJson {
341    label: String,
342    session_count: usize,
343    turn_count: usize,
344    output_tokens: u64,
345    context_tokens: u64,
346    cost: f64,
347    cost_per_turn: f64,
348}
349
350/// Build the typed `TrendJson` struct from a `TrendResult`.
351fn build_trend_json(trend: &TrendResult) -> TrendJson {
352    TrendJson {
353        group_label: trend.group_label.clone(),
354        entries: trend
355            .entries
356            .iter()
357            .map(|e| {
358                let cpt = if e.turn_count > 0 {
359                    e.cost / e.turn_count as f64
360                } else {
361                    0.0
362                };
363                TrendEntryJson {
364                    label: e.label.clone(),
365                    session_count: e.session_count,
366                    turn_count: e.turn_count,
367                    output_tokens: e.tokens.output_tokens,
368                    context_tokens: e.tokens.context_tokens(),
369                    cost: e.cost,
370                    cost_per_turn: cpt,
371                }
372            })
373            .collect(),
374    }
375}
376
377pub fn render_trend_json(trend: &TrendResult) -> String {
378    let json = build_trend_json(trend);
379    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
380}
381
382// ─── Wrapped JSON ──────────────────────────────────────────────────────────
383
384pub fn render_wrapped_json(result: &WrappedResult) -> String {
385    serde_json::to_string_pretty(result).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
386}
387
388// ─── Unified HTML Report Payload ───────────────────────────────────────────
389
390/// Unified JSON payload for the HTML dashboard.
391/// Combines data from all subcommands into a single structure.
392#[derive(Serialize)]
393pub struct HtmlReportPayload {
394    pub overview: serde_json::Value,
395    pub projects: serde_json::Value,
396    pub trends: serde_json::Value,
397    pub sessions: Vec<HtmlSessionSummary>,
398    pub heatmap: HeatmapPayload,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub wrapped: Option<serde_json::Value>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub active_session_id: Option<String>,
403}
404
405/// Per-session summary for the HTML dashboard.
406#[derive(Serialize)]
407pub struct HtmlSessionSummary {
408    pub id: String,
409    pub project: Option<String>,
410    pub turns: usize,
411    pub agent_turns: usize,
412    pub cost: f64,
413    pub duration_minutes: Option<f64>,
414    pub model: Option<String>,
415    pub cache_hit_rate: Option<f64>,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub first_timestamp: Option<String>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub last_timestamp: Option<String>,
420    // metadata
421    pub title: Option<String>,
422    #[serde(skip_serializing_if = "Vec::is_empty")]
423    pub tags: Vec<String>,
424    pub mode: Option<String>,
425}
426
427/// Heatmap data for the HTML dashboard.
428#[derive(Serialize)]
429pub struct HeatmapPayload {
430    pub days: Vec<DailyActivity>,
431}
432
433/// A single day's aggregated activity metrics.
434#[derive(Serialize)]
435pub struct DailyActivity {
436    pub date: String,
437    pub turns: usize,
438    pub cost: f64,
439    pub sessions: usize,
440}
441
442/// Build the unified HTML report payload.
443///
444/// Reuses existing `render_*_json` functions for overview/projects/trend,
445/// then builds session summaries and heatmap data directly from `SessionData`.
446pub fn render_html_payload(
447    overview: &OverviewResult,
448    projects: &ProjectResult,
449    trend: &TrendResult,
450    sessions: &[SessionData],
451    calc: &PricingCalculator,
452    wrapped: Option<&WrappedResult>,
453    active_session_id: Option<&str>,
454) -> String {
455    // Build typed structs and convert directly to serde_json::Value
456    let overview_json: serde_json::Value =
457        serde_json::to_value(build_overview_json(overview)).unwrap_or(serde_json::Value::Null);
458    let projects_json: serde_json::Value =
459        serde_json::to_value(build_projects_json(projects)).unwrap_or(serde_json::Value::Null);
460    let trends_json: serde_json::Value =
461        serde_json::to_value(build_trend_json(trend)).unwrap_or(serde_json::Value::Null);
462
463    // Build per-session summaries
464    let session_summaries: Vec<HtmlSessionSummary> = sessions
465        .iter()
466        .map(|s| build_html_session_summary(s, calc))
467        .collect();
468
469    // Build heatmap by aggregating sessions per date
470    let heatmap = build_heatmap(sessions, calc);
471
472    // Build wrapped data if available
473    let wrapped_json: Option<serde_json::Value> =
474        wrapped.and_then(|w| serde_json::to_value(w).ok());
475
476    let payload = HtmlReportPayload {
477        overview: overview_json,
478        projects: projects_json,
479        trends: trends_json,
480        sessions: session_summaries,
481        heatmap,
482        wrapped: wrapped_json,
483        active_session_id: active_session_id.map(|s| s.to_string()),
484    };
485
486    serde_json::to_string(&payload).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
487}
488
489/// Build an `HtmlSessionSummary` from a single `SessionData`.
490fn build_html_session_summary(
491    session: &SessionData,
492    calc: &PricingCalculator,
493) -> HtmlSessionSummary {
494    let all = session.all_responses();
495    let turn_count = all.len();
496    let agent_turn_count = session.agent_turn_count();
497
498    // Compute total cost and cache hit rate
499    let mut total_cost = 0.0;
500    let mut total_cache_read: u64 = 0;
501    let mut total_context: u64 = 0;
502    let mut model_counts: HashMap<&str, usize> = HashMap::new();
503
504    for turn in &all {
505        let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
506        total_cost += cost.total;
507
508        let input = turn.usage.input_tokens.unwrap_or(0);
509        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
510        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
511        let ctx = input + cache_create + cache_read;
512
513        total_context += ctx;
514        total_cache_read += cache_read;
515
516        *model_counts.entry(&turn.model).or_insert(0) += 1;
517    }
518
519    let cache_hit_rate = if total_context > 0 {
520        Some((total_cache_read as f64 / total_context as f64) * 100.0)
521    } else {
522        None
523    };
524
525    let primary_model = model_counts
526        .into_iter()
527        .max_by_key(|(_, count)| *count)
528        .map(|(m, _)| m.to_string());
529
530    let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
531        (Some(first), Some(last)) => Some((last - first).num_seconds() as f64 / 60.0),
532        _ => None,
533    };
534
535    HtmlSessionSummary {
536        id: session.session_id.clone(),
537        project: session.project.as_deref().map(project_display_name),
538        turns: turn_count,
539        agent_turns: agent_turn_count,
540        cost: total_cost,
541        duration_minutes,
542        model: primary_model,
543        cache_hit_rate,
544        first_timestamp: session.first_timestamp.map(|t| t.to_rfc3339()),
545        last_timestamp: session.last_timestamp.map(|t| t.to_rfc3339()),
546        title: session.metadata.title.clone(),
547        tags: session.metadata.tags.clone(),
548        mode: session.metadata.mode.clone(),
549    }
550}
551
552/// Aggregate sessions by date to build heatmap data.
553fn build_heatmap(sessions: &[SessionData], calc: &PricingCalculator) -> HeatmapPayload {
554    let mut daily_map: HashMap<String, (usize, f64, usize)> = HashMap::new(); // date -> (turns, cost, sessions)
555
556    for session in sessions {
557        // Use first_timestamp to determine the session's date
558        let date_key = match session.first_timestamp {
559            Some(ts) => {
560                let local = ts.with_timezone(&chrono::Local);
561                format!(
562                    "{:04}-{:02}-{:02}",
563                    local.year(),
564                    local.month(),
565                    local.day()
566                )
567            }
568            None => continue,
569        };
570
571        let all = session.all_responses();
572        let turn_count = all.len();
573        let mut session_cost = 0.0;
574        for turn in &all {
575            let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
576            session_cost += cost.total;
577        }
578
579        let entry = daily_map.entry(date_key).or_insert((0, 0.0, 0));
580        entry.0 += turn_count;
581        entry.1 += session_cost;
582        entry.2 += 1;
583    }
584
585    let mut days: Vec<DailyActivity> = daily_map
586        .into_iter()
587        .map(|(date, (turns, cost, session_count))| DailyActivity {
588            date,
589            turns,
590            cost,
591            sessions: session_count,
592        })
593        .collect();
594
595    // Sort by date ascending
596    days.sort_by(|a, b| a.date.cmp(&b.date));
597
598    HeatmapPayload { days }
599}