1use std::collections::HashMap;
23use std::path::Path;
24
25use axum::{
26 Json,
27 extract::{Path as AxumPath, State},
28 http::StatusCode,
29 response::IntoResponse,
30};
31use chrono::{Days, NaiveDate, Utc};
32use serde::Serialize;
33
34use crate::core::savings_ledger::SignedSavingsBatchV1;
35
36use super::team::TeamAppState;
37
38const SERIES_WINDOW_DAYS: u32 = 90;
40const MAX_BREAKDOWN_ROWS: usize = 10;
42
43#[derive(Debug, Default, Serialize)]
45pub struct TeamSavingsSummary {
46 pub schema_version: u32,
47 pub generated_at: String,
48 pub member_count: usize,
50 pub totals: SavingsTotals,
51 pub by_member: Vec<MemberSavings>,
53 pub by_model: Vec<ModelRow>,
55 pub by_tool: Vec<ToolRow>,
57 pub series: Vec<SeriesPoint>,
60 pub window_days: u32,
62}
63
64#[derive(Debug, Default, Serialize)]
65pub struct SavingsTotals {
66 pub saved_tokens: u64,
68 pub net_saved_tokens: u64,
70 pub saved_usd: f64,
72 pub total_events: u64,
74}
75
76#[derive(Debug, Serialize)]
77pub struct MemberSavings {
78 pub signer: String,
80 pub agent_id: String,
81 pub saved_tokens: u64,
82 pub net_saved_tokens: u64,
83 pub saved_usd: f64,
84 pub total_events: u64,
86 pub last_reported: String,
88}
89
90#[derive(Debug, Serialize)]
91pub struct ModelRow {
92 pub model: String,
93 pub saved_tokens: u64,
94 pub saved_usd: f64,
95}
96
97#[derive(Debug, Serialize)]
98pub struct ToolRow {
99 pub tool: String,
100 pub saved_tokens: u64,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize)]
107pub struct SeriesPoint {
108 pub date: String,
110 pub net_saved_tokens: u64,
111 pub saved_usd: f64,
112 pub total_events: u64,
113}
114
115#[derive(Debug, Clone, Copy)]
117struct DayPoint {
118 date: NaiveDate,
119 net_saved_tokens: u64,
120 saved_usd: f64,
121 total_events: u64,
122}
123
124#[derive(Debug, Serialize)]
128pub struct MemberDrilldown {
129 pub schema_version: u32,
130 pub generated_at: String,
131 pub signer: String,
133 pub agent_id: String,
134 pub last_reported: String,
136 pub totals: SavingsTotals,
137 pub by_model: Vec<ModelRow>,
139 pub by_tool: Vec<ToolRow>,
141 pub series: Vec<SeriesPoint>,
143 pub window_days: u32,
144}
145
146pub async fn v1_savings_summary(State(state): State<TeamAppState>) -> impl IntoResponse {
147 let dir = state.team.savings_store_dir.lock().await.clone();
148 let summary = tokio::task::spawn_blocking(move || aggregate(&dir))
149 .await
150 .unwrap_or_default();
151 (StatusCode::OK, Json(summary))
152}
153
154pub async fn v1_savings_member(
160 State(state): State<TeamAppState>,
161 AxumPath(signer): AxumPath<String>,
162) -> axum::response::Response {
163 if !is_valid_signer_prefix(&signer) {
164 return super::json_error(
165 StatusCode::BAD_REQUEST,
166 "invalid_signer",
167 "signer must be 1-64 chars of [A-Za-z0-9_-]",
168 );
169 }
170 let dir = state.team.savings_store_dir.lock().await.clone();
171 let drill = tokio::task::spawn_blocking(move || member_drilldown(&dir, &signer))
172 .await
173 .ok()
174 .flatten();
175 match drill {
176 Some(d) => (StatusCode::OK, Json(d)).into_response(),
177 None => super::json_error(
178 StatusCode::NOT_FOUND,
179 "unknown_member",
180 "no savings batches reported for this signer",
181 ),
182 }
183}
184
185fn is_valid_signer_prefix(s: &str) -> bool {
188 !s.is_empty()
189 && s.len() <= 64
190 && s.bytes()
191 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
192}
193
194pub(super) fn member_drilldown(dir: &Path, signer: &str) -> Option<MemberDrilldown> {
197 let truncated: String = signer.chars().take(16).collect();
200 let path = dir.join(format!("savings_{truncated}.jsonl"));
201
202 let batches = read_all_batches(&path);
203 let latest = batches.last()?;
204
205 let mut by_model: Vec<ModelRow> = latest
206 .totals
207 .by_model
208 .iter()
209 .map(|(model, tokens, usd)| ModelRow {
210 model: model.clone(),
211 saved_tokens: *tokens,
212 saved_usd: round_usd(*usd),
213 })
214 .collect();
215 by_model.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
216 by_model.truncate(MAX_BREAKDOWN_ROWS);
217
218 let mut by_tool: Vec<ToolRow> = latest
219 .totals
220 .by_tool
221 .iter()
222 .map(|(tool, tokens)| ToolRow {
223 tool: tool.clone(),
224 saved_tokens: *tokens,
225 })
226 .collect();
227 by_tool.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
228 by_tool.truncate(MAX_BREAKDOWN_ROWS);
229
230 let mut points: Vec<DayPoint> = batches
231 .iter()
232 .filter_map(|b| {
233 parse_date(&b.created_at).map(|date| DayPoint {
234 date,
235 net_saved_tokens: b.totals.net_saved_tokens,
236 saved_usd: b.totals.saved_usd,
237 total_events: b.totals.total_events as u64,
238 })
239 })
240 .collect();
241 points.sort_by_key(|p| p.date);
242 let series = build_series(
243 std::slice::from_ref(&points),
244 Utc::now().date_naive(),
245 SERIES_WINDOW_DAYS,
246 );
247
248 Some(MemberDrilldown {
249 schema_version: 1,
250 generated_at: Utc::now().to_rfc3339(),
251 signer: truncated,
252 agent_id: latest.agent_id.clone(),
253 last_reported: latest.created_at.clone(),
254 totals: SavingsTotals {
255 saved_tokens: latest.totals.saved_tokens,
256 net_saved_tokens: latest.totals.net_saved_tokens,
257 saved_usd: round_usd(latest.totals.saved_usd),
258 total_events: latest.totals.total_events as u64,
259 },
260 by_model,
261 by_tool,
262 series,
263 window_days: SERIES_WINDOW_DAYS,
264 })
265}
266
267pub(super) fn aggregate(dir: &Path) -> TeamSavingsSummary {
271 let mut members: Vec<MemberSavings> = Vec::new();
272 let mut model_totals: HashMap<String, (u64, f64)> = HashMap::new();
273 let mut tool_totals: HashMap<String, u64> = HashMap::new();
274 let mut totals = SavingsTotals::default();
275 let mut signer_points: Vec<Vec<DayPoint>> = Vec::new();
276
277 let Ok(entries) = std::fs::read_dir(dir) else {
278 return finalize(totals, members, model_totals, tool_totals, &signer_points);
279 };
280
281 for entry in entries.flatten() {
282 let path = entry.path();
283 let named_savings = path
284 .file_name()
285 .and_then(|n| n.to_str())
286 .is_some_and(|n| n.starts_with("savings_"));
287 let is_jsonl = path
288 .extension()
289 .is_some_and(|e| e.eq_ignore_ascii_case("jsonl"));
290 if !(named_savings && is_jsonl) {
291 continue;
292 }
293
294 let batches = read_all_batches(&path);
295 let Some(batch) = batches.last() else {
296 continue;
297 };
298
299 totals.saved_tokens = totals
300 .saved_tokens
301 .saturating_add(batch.totals.saved_tokens);
302 totals.net_saved_tokens = totals
303 .net_saved_tokens
304 .saturating_add(batch.totals.net_saved_tokens);
305 totals.saved_usd += batch.totals.saved_usd;
306 totals.total_events = totals
307 .total_events
308 .saturating_add(batch.totals.total_events as u64);
309
310 for (model, tokens, usd) in &batch.totals.by_model {
311 let acc = model_totals.entry(model.clone()).or_default();
312 acc.0 = acc.0.saturating_add(*tokens);
313 acc.1 += *usd;
314 }
315 for (tool, tokens) in &batch.totals.by_tool {
316 let acc = tool_totals.entry(tool.clone()).or_default();
317 *acc = acc.saturating_add(*tokens);
318 }
319
320 let signer = batch.signer_public_key.as_deref().unwrap_or("unknown");
321 members.push(MemberSavings {
322 signer: signer.chars().take(16).collect(),
323 agent_id: batch.agent_id.clone(),
324 saved_tokens: batch.totals.saved_tokens,
325 net_saved_tokens: batch.totals.net_saved_tokens,
326 saved_usd: round_usd(batch.totals.saved_usd),
327 total_events: batch.totals.total_events as u64,
328 last_reported: batch.created_at.clone(),
329 });
330
331 let mut points: Vec<DayPoint> = batches
332 .iter()
333 .filter_map(|b| {
334 parse_date(&b.created_at).map(|date| DayPoint {
335 date,
336 net_saved_tokens: b.totals.net_saved_tokens,
337 saved_usd: b.totals.saved_usd,
338 total_events: b.totals.total_events as u64,
339 })
340 })
341 .collect();
342 points.sort_by_key(|p| p.date);
343 signer_points.push(points);
344 }
345
346 finalize(totals, members, model_totals, tool_totals, &signer_points)
347}
348
349fn finalize(
350 mut totals: SavingsTotals,
351 mut members: Vec<MemberSavings>,
352 model_totals: HashMap<String, (u64, f64)>,
353 tool_totals: HashMap<String, u64>,
354 signer_points: &[Vec<DayPoint>],
355) -> TeamSavingsSummary {
356 totals.saved_usd = round_usd(totals.saved_usd);
357 members.sort_by_key(|m| std::cmp::Reverse(m.net_saved_tokens));
358
359 let mut by_model: Vec<ModelRow> = model_totals
360 .into_iter()
361 .map(|(model, (saved_tokens, usd))| ModelRow {
362 model,
363 saved_tokens,
364 saved_usd: round_usd(usd),
365 })
366 .collect();
367 by_model.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
368 by_model.truncate(MAX_BREAKDOWN_ROWS);
369
370 let mut by_tool: Vec<ToolRow> = tool_totals
371 .into_iter()
372 .map(|(tool, saved_tokens)| ToolRow { tool, saved_tokens })
373 .collect();
374 by_tool.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
375 by_tool.truncate(MAX_BREAKDOWN_ROWS);
376
377 let series = build_series(signer_points, Utc::now().date_naive(), SERIES_WINDOW_DAYS);
378
379 TeamSavingsSummary {
380 schema_version: 2,
381 generated_at: Utc::now().to_rfc3339(),
382 member_count: members.len(),
383 totals,
384 by_member: members,
385 by_model,
386 by_tool,
387 series,
388 window_days: SERIES_WINDOW_DAYS,
389 }
390}
391
392fn build_series(
398 signer_points: &[Vec<DayPoint>],
399 today: NaiveDate,
400 window_days: u32,
401) -> Vec<SeriesPoint> {
402 if window_days == 0 || signer_points.iter().all(Vec::is_empty) {
403 return Vec::new();
404 }
405 let start = today - Days::new(u64::from(window_days.saturating_sub(1)));
406
407 let mut cursor = vec![0usize; signer_points.len()];
410 let mut carried = vec![(0u64, 0f64, 0u64); signer_points.len()];
411
412 let mut out: Vec<SeriesPoint> = Vec::with_capacity(window_days as usize);
413 let mut day = start;
414 while day <= today {
415 let mut net = 0u64;
416 let mut usd = 0f64;
417 let mut events = 0u64;
418 for (si, points) in signer_points.iter().enumerate() {
419 while cursor[si] < points.len() && points[cursor[si]].date <= day {
420 let p = points[cursor[si]];
421 carried[si] = (p.net_saved_tokens, p.saved_usd, p.total_events);
422 cursor[si] += 1;
423 }
424 net = net.saturating_add(carried[si].0);
425 usd += carried[si].1;
426 events = events.saturating_add(carried[si].2);
427 }
428 out.push(SeriesPoint {
429 date: day.format("%Y-%m-%d").to_string(),
430 net_saved_tokens: net,
431 saved_usd: round_usd(usd),
432 total_events: events,
433 });
434 match day.succ_opt() {
435 Some(next) => day = next,
436 None => break,
437 }
438 }
439 out
440}
441
442fn read_all_batches(path: &Path) -> Vec<SignedSavingsBatchV1> {
445 let Ok(content) = std::fs::read_to_string(path) else {
446 return Vec::new();
447 };
448 content
449 .lines()
450 .filter_map(|line| {
451 let line = line.trim();
452 if line.is_empty() {
453 return None;
454 }
455 serde_json::from_str::<SignedSavingsBatchV1>(line).ok()
456 })
457 .collect()
458}
459
460fn parse_date(created_at: &str) -> Option<NaiveDate> {
462 chrono::DateTime::parse_from_rfc3339(created_at)
463 .ok()
464 .map(|dt| dt.with_timezone(&Utc).date_naive())
465}
466
467fn round_usd(v: f64) -> f64 {
468 (v * 1_000_000.0).round() / 1_000_000.0
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::core::savings_ledger::signed_batch::BatchTotals;
475
476 fn batch(signer: &str, net: u64, usd: f64, created_at: &str) -> SignedSavingsBatchV1 {
477 SignedSavingsBatchV1 {
478 schema_version: 1,
479 kind: "lean-ctx.savings-batch".into(),
480 created_at: created_at.into(),
481 lean_ctx_version: "test".into(),
482 agent_id: format!("agent-{signer}"),
483 period: "all".into(),
484 first_entry_hash: "genesis".into(),
485 last_entry_hash: "head".into(),
486 chain_valid: true,
487 totals: BatchTotals {
488 total_events: 1,
489 saved_tokens: net,
490 net_saved_tokens: net,
491 saved_usd: usd,
492 bounce_tokens: 0,
493 bounce_events: 0,
494 tokenizers: vec!["o200k_base".into()],
495 by_model: vec![("claude-opus".into(), net, usd)],
496 by_tool: vec![("ctx_read".into(), net)],
497 by_mechanism: vec![("compression".into(), net, usd)],
498 },
499 signer_public_key: Some(signer.into()),
500 signature: Some("sig".into()),
501 }
502 }
503
504 fn write_lines(dir: &Path, file: &str, batches: &[SignedSavingsBatchV1]) {
505 let body = batches
506 .iter()
507 .map(|b| serde_json::to_string(b).unwrap())
508 .collect::<Vec<_>>()
509 .join("\n");
510 std::fs::write(dir.join(file), body + "\n").unwrap();
511 }
512
513 fn temp_dir(tag: &str) -> std::path::PathBuf {
514 let d = std::env::temp_dir().join(format!(
515 "leanctx_savings_summary_{tag}_{}",
516 std::process::id()
517 ));
518 let _ = std::fs::remove_dir_all(&d);
519 std::fs::create_dir_all(&d).unwrap();
520 d
521 }
522
523 fn day(s: &str) -> NaiveDate {
524 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
525 }
526
527 fn points(raw: &[(&str, u64, f64, u64)]) -> Vec<DayPoint> {
528 raw.iter()
529 .map(|(d, net, usd, ev)| DayPoint {
530 date: day(d),
531 net_saved_tokens: *net,
532 saved_usd: *usd,
533 total_events: *ev,
534 })
535 .collect()
536 }
537
538 #[test]
539 fn latest_batch_per_signer_is_not_double_counted() {
540 let dir = temp_dir("nodouble");
541 write_lines(
543 &dir,
544 "savings_aaaaaaaaaaaaaaaa.jsonl",
545 &[
546 batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
547 batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-08T00:00:00Z"),
548 ],
549 );
550 write_lines(
552 &dir,
553 "savings_bbbbbbbbbbbbbbbb.jsonl",
554 &[batch(
555 "bbbbbbbbbbbbbbbb",
556 2000,
557 0.02,
558 "2026-06-07T00:00:00Z",
559 )],
560 );
561
562 let s = aggregate(&dir);
563 assert_eq!(s.schema_version, 2);
564 assert_eq!(s.member_count, 2);
565 assert_eq!(s.totals.net_saved_tokens, 5000);
567 assert_eq!(s.totals.total_events, 2);
569 assert_eq!(s.by_member[0].net_saved_tokens, 3000);
571 assert_eq!(s.by_member[1].net_saved_tokens, 2000);
572 assert_eq!(s.by_member[0].total_events, 1);
573 assert_eq!(s.by_model[0].model, "claude-opus");
575 assert_eq!(s.by_model[0].saved_tokens, 5000);
576 assert_eq!(s.by_tool[0].tool, "ctx_read");
577 assert_eq!(s.by_tool[0].saved_tokens, 5000);
578
579 let _ = std::fs::remove_dir_all(&dir);
580 }
581
582 #[test]
583 fn empty_or_missing_store_is_zeroed() {
584 let missing = std::env::temp_dir().join("leanctx_savings_summary_does_not_exist_xyz");
585 let _ = std::fs::remove_dir_all(&missing);
586 let s = aggregate(&missing);
587 assert_eq!(s.member_count, 0);
588 assert_eq!(s.totals.net_saved_tokens, 0);
589 assert!(s.by_member.is_empty());
590 assert!(s.series.is_empty());
591 assert_eq!(s.window_days, SERIES_WINDOW_DAYS);
592 }
593
594 #[test]
595 fn non_savings_files_are_ignored() {
596 let dir = temp_dir("ignore");
597 std::fs::write(dir.join("audit.jsonl"), "{\"not\":\"a batch\"}\n").unwrap();
598 std::fs::write(dir.join("README.md"), "hello\n").unwrap();
599 write_lines(
600 &dir,
601 "savings_cccccccccccccccc.jsonl",
602 &[batch(
603 "cccccccccccccccc",
604 700,
605 0.007,
606 "2026-06-08T00:00:00Z",
607 )],
608 );
609 let s = aggregate(&dir);
610 assert_eq!(s.member_count, 1);
611 assert_eq!(s.totals.net_saved_tokens, 700);
612 let _ = std::fs::remove_dir_all(&dir);
613 }
614
615 #[test]
616 fn series_carries_each_signer_snapshot_forward_and_sums() {
617 let a = points(&[
620 ("2026-06-01", 1000, 0.01, 10),
621 ("2026-06-03", 3000, 0.03, 30),
622 ]);
623 let b = points(&[("2026-06-02", 2000, 0.02, 20)]);
624 let series = build_series(&[a, b], day("2026-06-04"), 4);
625
626 assert_eq!(series.len(), 4);
628 assert_eq!(series[0].date, "2026-06-01");
630 assert_eq!(series[0].net_saved_tokens, 1000);
631 assert_eq!(series[0].total_events, 10);
632 assert_eq!(series[1].net_saved_tokens, 3000);
634 assert_eq!(series[1].total_events, 30);
635 assert_eq!(series[2].net_saved_tokens, 5000);
637 assert_eq!(series[3].net_saved_tokens, 5000);
639 assert_eq!(series[3].total_events, 50);
640 assert!((series[3].saved_usd - 0.05).abs() < 1e-9);
641 }
642
643 #[test]
644 fn series_window_clips_to_recent_days_only() {
645 let a = points(&[("2026-01-01", 5000, 0.5, 100)]);
648 let series = build_series(&[a], day("2026-06-03"), 3);
649 assert_eq!(series.len(), 3);
650 assert_eq!(series[0].date, "2026-06-01");
651 assert_eq!(series[0].net_saved_tokens, 5000);
653 assert_eq!(series[2].net_saved_tokens, 5000);
654 }
655
656 #[test]
657 fn series_is_empty_without_points() {
658 let series = build_series(&[Vec::new(), Vec::new()], day("2026-06-03"), 30);
659 assert!(series.is_empty());
660 }
661
662 #[test]
663 fn member_drilldown_returns_latest_breakdowns_and_own_series() {
664 let dir = temp_dir("drill");
665 write_lines(
668 &dir,
669 "savings_aaaaaaaaaaaaaaaa.jsonl",
670 &[
671 batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
672 batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-03T00:00:00Z"),
673 ],
674 );
675 write_lines(
677 &dir,
678 "savings_bbbbbbbbbbbbbbbb.jsonl",
679 &[batch(
680 "bbbbbbbbbbbbbbbb",
681 9999,
682 0.99,
683 "2026-06-02T00:00:00Z",
684 )],
685 );
686
687 let d = member_drilldown(&dir, "aaaaaaaaaaaaaaaa").expect("drilldown");
688 assert_eq!(d.signer, "aaaaaaaaaaaaaaaa");
689 assert_eq!(d.agent_id, "agent-aaaaaaaaaaaaaaaa");
690 assert_eq!(d.totals.net_saved_tokens, 3000);
691 assert_eq!(d.last_reported, "2026-06-03T00:00:00Z");
692 assert_eq!(d.by_model.len(), 1);
693 assert_eq!(d.by_model[0].model, "claude-opus");
694 assert_eq!(d.by_model[0].saved_tokens, 3000);
695 assert_eq!(d.by_tool[0].tool, "ctx_read");
696 assert_eq!(d.window_days, SERIES_WINDOW_DAYS);
697 let last = d.series.last().expect("series");
700 assert_eq!(last.net_saved_tokens, 3000);
701 assert_eq!(last.total_events, 1);
702
703 let _ = std::fs::remove_dir_all(&dir);
704 }
705
706 #[test]
707 fn member_drilldown_unknown_signer_is_none() {
708 let dir = temp_dir("drillmissing");
709 assert!(member_drilldown(&dir, "cccccccccccccccc").is_none());
710 let _ = std::fs::remove_dir_all(&dir);
711 }
712
713 #[test]
714 fn signer_prefix_validation_rejects_path_chars() {
715 assert!(is_valid_signer_prefix("aaaaaaaaaaaaaaaa"));
716 assert!(is_valid_signer_prefix("AbC123_-"));
717 assert!(!is_valid_signer_prefix(""));
718 assert!(!is_valid_signer_prefix("../../etc/passwd"));
719 assert!(!is_valid_signer_prefix("a/b"));
720 assert!(!is_valid_signer_prefix("a.b"));
721 assert!(!is_valid_signer_prefix(&"a".repeat(65)));
722 }
723}