1use std::collections::HashMap;
23use std::path::Path;
24
25use axum::{
26 extract::{Path as AxumPath, State},
27 http::StatusCode,
28 response::IntoResponse,
29 Json,
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 },
498 signer_public_key: Some(signer.into()),
499 signature: Some("sig".into()),
500 }
501 }
502
503 fn write_lines(dir: &Path, file: &str, batches: &[SignedSavingsBatchV1]) {
504 let body = batches
505 .iter()
506 .map(|b| serde_json::to_string(b).unwrap())
507 .collect::<Vec<_>>()
508 .join("\n");
509 std::fs::write(dir.join(file), body + "\n").unwrap();
510 }
511
512 fn temp_dir(tag: &str) -> std::path::PathBuf {
513 let d = std::env::temp_dir().join(format!(
514 "leanctx_savings_summary_{tag}_{}",
515 std::process::id()
516 ));
517 let _ = std::fs::remove_dir_all(&d);
518 std::fs::create_dir_all(&d).unwrap();
519 d
520 }
521
522 fn day(s: &str) -> NaiveDate {
523 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
524 }
525
526 fn points(raw: &[(&str, u64, f64, u64)]) -> Vec<DayPoint> {
527 raw.iter()
528 .map(|(d, net, usd, ev)| DayPoint {
529 date: day(d),
530 net_saved_tokens: *net,
531 saved_usd: *usd,
532 total_events: *ev,
533 })
534 .collect()
535 }
536
537 #[test]
538 fn latest_batch_per_signer_is_not_double_counted() {
539 let dir = temp_dir("nodouble");
540 write_lines(
542 &dir,
543 "savings_aaaaaaaaaaaaaaaa.jsonl",
544 &[
545 batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
546 batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-08T00:00:00Z"),
547 ],
548 );
549 write_lines(
551 &dir,
552 "savings_bbbbbbbbbbbbbbbb.jsonl",
553 &[batch(
554 "bbbbbbbbbbbbbbbb",
555 2000,
556 0.02,
557 "2026-06-07T00:00:00Z",
558 )],
559 );
560
561 let s = aggregate(&dir);
562 assert_eq!(s.schema_version, 2);
563 assert_eq!(s.member_count, 2);
564 assert_eq!(s.totals.net_saved_tokens, 5000);
566 assert_eq!(s.totals.total_events, 2);
568 assert_eq!(s.by_member[0].net_saved_tokens, 3000);
570 assert_eq!(s.by_member[1].net_saved_tokens, 2000);
571 assert_eq!(s.by_member[0].total_events, 1);
572 assert_eq!(s.by_model[0].model, "claude-opus");
574 assert_eq!(s.by_model[0].saved_tokens, 5000);
575 assert_eq!(s.by_tool[0].tool, "ctx_read");
576 assert_eq!(s.by_tool[0].saved_tokens, 5000);
577
578 let _ = std::fs::remove_dir_all(&dir);
579 }
580
581 #[test]
582 fn empty_or_missing_store_is_zeroed() {
583 let missing = std::env::temp_dir().join("leanctx_savings_summary_does_not_exist_xyz");
584 let _ = std::fs::remove_dir_all(&missing);
585 let s = aggregate(&missing);
586 assert_eq!(s.member_count, 0);
587 assert_eq!(s.totals.net_saved_tokens, 0);
588 assert!(s.by_member.is_empty());
589 assert!(s.series.is_empty());
590 assert_eq!(s.window_days, SERIES_WINDOW_DAYS);
591 }
592
593 #[test]
594 fn non_savings_files_are_ignored() {
595 let dir = temp_dir("ignore");
596 std::fs::write(dir.join("audit.jsonl"), "{\"not\":\"a batch\"}\n").unwrap();
597 std::fs::write(dir.join("README.md"), "hello\n").unwrap();
598 write_lines(
599 &dir,
600 "savings_cccccccccccccccc.jsonl",
601 &[batch(
602 "cccccccccccccccc",
603 700,
604 0.007,
605 "2026-06-08T00:00:00Z",
606 )],
607 );
608 let s = aggregate(&dir);
609 assert_eq!(s.member_count, 1);
610 assert_eq!(s.totals.net_saved_tokens, 700);
611 let _ = std::fs::remove_dir_all(&dir);
612 }
613
614 #[test]
615 fn series_carries_each_signer_snapshot_forward_and_sums() {
616 let a = points(&[
619 ("2026-06-01", 1000, 0.01, 10),
620 ("2026-06-03", 3000, 0.03, 30),
621 ]);
622 let b = points(&[("2026-06-02", 2000, 0.02, 20)]);
623 let series = build_series(&[a, b], day("2026-06-04"), 4);
624
625 assert_eq!(series.len(), 4);
627 assert_eq!(series[0].date, "2026-06-01");
629 assert_eq!(series[0].net_saved_tokens, 1000);
630 assert_eq!(series[0].total_events, 10);
631 assert_eq!(series[1].net_saved_tokens, 3000);
633 assert_eq!(series[1].total_events, 30);
634 assert_eq!(series[2].net_saved_tokens, 5000);
636 assert_eq!(series[3].net_saved_tokens, 5000);
638 assert_eq!(series[3].total_events, 50);
639 assert!((series[3].saved_usd - 0.05).abs() < 1e-9);
640 }
641
642 #[test]
643 fn series_window_clips_to_recent_days_only() {
644 let a = points(&[("2026-01-01", 5000, 0.5, 100)]);
647 let series = build_series(&[a], day("2026-06-03"), 3);
648 assert_eq!(series.len(), 3);
649 assert_eq!(series[0].date, "2026-06-01");
650 assert_eq!(series[0].net_saved_tokens, 5000);
652 assert_eq!(series[2].net_saved_tokens, 5000);
653 }
654
655 #[test]
656 fn series_is_empty_without_points() {
657 let series = build_series(&[Vec::new(), Vec::new()], day("2026-06-03"), 30);
658 assert!(series.is_empty());
659 }
660
661 #[test]
662 fn member_drilldown_returns_latest_breakdowns_and_own_series() {
663 let dir = temp_dir("drill");
664 write_lines(
667 &dir,
668 "savings_aaaaaaaaaaaaaaaa.jsonl",
669 &[
670 batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
671 batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-03T00:00:00Z"),
672 ],
673 );
674 write_lines(
676 &dir,
677 "savings_bbbbbbbbbbbbbbbb.jsonl",
678 &[batch(
679 "bbbbbbbbbbbbbbbb",
680 9999,
681 0.99,
682 "2026-06-02T00:00:00Z",
683 )],
684 );
685
686 let d = member_drilldown(&dir, "aaaaaaaaaaaaaaaa").expect("drilldown");
687 assert_eq!(d.signer, "aaaaaaaaaaaaaaaa");
688 assert_eq!(d.agent_id, "agent-aaaaaaaaaaaaaaaa");
689 assert_eq!(d.totals.net_saved_tokens, 3000);
690 assert_eq!(d.last_reported, "2026-06-03T00:00:00Z");
691 assert_eq!(d.by_model.len(), 1);
692 assert_eq!(d.by_model[0].model, "claude-opus");
693 assert_eq!(d.by_model[0].saved_tokens, 3000);
694 assert_eq!(d.by_tool[0].tool, "ctx_read");
695 assert_eq!(d.window_days, SERIES_WINDOW_DAYS);
696 let last = d.series.last().expect("series");
699 assert_eq!(last.net_saved_tokens, 3000);
700 assert_eq!(last.total_events, 1);
701
702 let _ = std::fs::remove_dir_all(&dir);
703 }
704
705 #[test]
706 fn member_drilldown_unknown_signer_is_none() {
707 let dir = temp_dir("drillmissing");
708 assert!(member_drilldown(&dir, "cccccccccccccccc").is_none());
709 let _ = std::fs::remove_dir_all(&dir);
710 }
711
712 #[test]
713 fn signer_prefix_validation_rejects_path_chars() {
714 assert!(is_valid_signer_prefix("aaaaaaaaaaaaaaaa"));
715 assert!(is_valid_signer_prefix("AbC123_-"));
716 assert!(!is_valid_signer_prefix(""));
717 assert!(!is_valid_signer_prefix("../../etc/passwd"));
718 assert!(!is_valid_signer_prefix("a/b"));
719 assert!(!is_valid_signer_prefix("a.b"));
720 assert!(!is_valid_signer_prefix(&"a".repeat(65)));
721 }
722}