use std::collections::HashMap;
use std::path::Path;
use axum::{
Json,
extract::{Path as AxumPath, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{Days, NaiveDate, Utc};
use serde::Serialize;
use crate::core::savings_ledger::SignedSavingsBatchV1;
use super::team::TeamAppState;
const SERIES_WINDOW_DAYS: u32 = 90;
const MAX_BREAKDOWN_ROWS: usize = 10;
#[derive(Debug, Default, Serialize)]
pub struct TeamSavingsSummary {
pub schema_version: u32,
pub generated_at: String,
pub member_count: usize,
pub totals: SavingsTotals,
pub by_member: Vec<MemberSavings>,
pub by_model: Vec<ModelRow>,
pub by_tool: Vec<ToolRow>,
pub series: Vec<SeriesPoint>,
pub window_days: u32,
}
#[derive(Debug, Default, Serialize)]
pub struct SavingsTotals {
pub saved_tokens: u64,
pub net_saved_tokens: u64,
pub saved_usd: f64,
pub total_events: u64,
}
#[derive(Debug, Serialize)]
pub struct MemberSavings {
pub signer: String,
pub agent_id: String,
pub saved_tokens: u64,
pub net_saved_tokens: u64,
pub saved_usd: f64,
pub total_events: u64,
pub last_reported: String,
}
#[derive(Debug, Serialize)]
pub struct ModelRow {
pub model: String,
pub saved_tokens: u64,
pub saved_usd: f64,
}
#[derive(Debug, Serialize)]
pub struct ToolRow {
pub tool: String,
pub saved_tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SeriesPoint {
pub date: String,
pub net_saved_tokens: u64,
pub saved_usd: f64,
pub total_events: u64,
}
#[derive(Debug, Clone, Copy)]
struct DayPoint {
date: NaiveDate,
net_saved_tokens: u64,
saved_usd: f64,
total_events: u64,
}
#[derive(Debug, Serialize)]
pub struct MemberDrilldown {
pub schema_version: u32,
pub generated_at: String,
pub signer: String,
pub agent_id: String,
pub last_reported: String,
pub totals: SavingsTotals,
pub by_model: Vec<ModelRow>,
pub by_tool: Vec<ToolRow>,
pub series: Vec<SeriesPoint>,
pub window_days: u32,
}
pub async fn v1_savings_summary(State(state): State<TeamAppState>) -> impl IntoResponse {
let dir = state.team.savings_store_dir.lock().await.clone();
let summary = tokio::task::spawn_blocking(move || aggregate(&dir))
.await
.unwrap_or_default();
(StatusCode::OK, Json(summary))
}
pub async fn v1_savings_member(
State(state): State<TeamAppState>,
AxumPath(signer): AxumPath<String>,
) -> axum::response::Response {
if !is_valid_signer_prefix(&signer) {
return super::json_error(
StatusCode::BAD_REQUEST,
"invalid_signer",
"signer must be 1-64 chars of [A-Za-z0-9_-]",
);
}
let dir = state.team.savings_store_dir.lock().await.clone();
let drill = tokio::task::spawn_blocking(move || member_drilldown(&dir, &signer))
.await
.ok()
.flatten();
match drill {
Some(d) => (StatusCode::OK, Json(d)).into_response(),
None => super::json_error(
StatusCode::NOT_FOUND,
"unknown_member",
"no savings batches reported for this signer",
),
}
}
fn is_valid_signer_prefix(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
pub(super) fn member_drilldown(dir: &Path, signer: &str) -> Option<MemberDrilldown> {
let truncated: String = signer.chars().take(16).collect();
let path = dir.join(format!("savings_{truncated}.jsonl"));
let batches = read_all_batches(&path);
let latest = batches.last()?;
let mut by_model: Vec<ModelRow> = latest
.totals
.by_model
.iter()
.map(|(model, tokens, usd)| ModelRow {
model: model.clone(),
saved_tokens: *tokens,
saved_usd: round_usd(*usd),
})
.collect();
by_model.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
by_model.truncate(MAX_BREAKDOWN_ROWS);
let mut by_tool: Vec<ToolRow> = latest
.totals
.by_tool
.iter()
.map(|(tool, tokens)| ToolRow {
tool: tool.clone(),
saved_tokens: *tokens,
})
.collect();
by_tool.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
by_tool.truncate(MAX_BREAKDOWN_ROWS);
let mut points: Vec<DayPoint> = batches
.iter()
.filter_map(|b| {
parse_date(&b.created_at).map(|date| DayPoint {
date,
net_saved_tokens: b.totals.net_saved_tokens,
saved_usd: b.totals.saved_usd,
total_events: b.totals.total_events as u64,
})
})
.collect();
points.sort_by_key(|p| p.date);
let series = build_series(
std::slice::from_ref(&points),
Utc::now().date_naive(),
SERIES_WINDOW_DAYS,
);
Some(MemberDrilldown {
schema_version: 1,
generated_at: Utc::now().to_rfc3339(),
signer: truncated,
agent_id: latest.agent_id.clone(),
last_reported: latest.created_at.clone(),
totals: SavingsTotals {
saved_tokens: latest.totals.saved_tokens,
net_saved_tokens: latest.totals.net_saved_tokens,
saved_usd: round_usd(latest.totals.saved_usd),
total_events: latest.totals.total_events as u64,
},
by_model,
by_tool,
series,
window_days: SERIES_WINDOW_DAYS,
})
}
pub(super) fn aggregate(dir: &Path) -> TeamSavingsSummary {
let mut members: Vec<MemberSavings> = Vec::new();
let mut model_totals: HashMap<String, (u64, f64)> = HashMap::new();
let mut tool_totals: HashMap<String, u64> = HashMap::new();
let mut totals = SavingsTotals::default();
let mut signer_points: Vec<Vec<DayPoint>> = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return finalize(totals, members, model_totals, tool_totals, &signer_points);
};
for entry in entries.flatten() {
let path = entry.path();
let named_savings = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("savings_"));
let is_jsonl = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("jsonl"));
if !(named_savings && is_jsonl) {
continue;
}
let batches = read_all_batches(&path);
let Some(batch) = batches.last() else {
continue;
};
totals.saved_tokens = totals
.saved_tokens
.saturating_add(batch.totals.saved_tokens);
totals.net_saved_tokens = totals
.net_saved_tokens
.saturating_add(batch.totals.net_saved_tokens);
totals.saved_usd += batch.totals.saved_usd;
totals.total_events = totals
.total_events
.saturating_add(batch.totals.total_events as u64);
for (model, tokens, usd) in &batch.totals.by_model {
let acc = model_totals.entry(model.clone()).or_default();
acc.0 = acc.0.saturating_add(*tokens);
acc.1 += *usd;
}
for (tool, tokens) in &batch.totals.by_tool {
let acc = tool_totals.entry(tool.clone()).or_default();
*acc = acc.saturating_add(*tokens);
}
let signer = batch.signer_public_key.as_deref().unwrap_or("unknown");
members.push(MemberSavings {
signer: signer.chars().take(16).collect(),
agent_id: batch.agent_id.clone(),
saved_tokens: batch.totals.saved_tokens,
net_saved_tokens: batch.totals.net_saved_tokens,
saved_usd: round_usd(batch.totals.saved_usd),
total_events: batch.totals.total_events as u64,
last_reported: batch.created_at.clone(),
});
let mut points: Vec<DayPoint> = batches
.iter()
.filter_map(|b| {
parse_date(&b.created_at).map(|date| DayPoint {
date,
net_saved_tokens: b.totals.net_saved_tokens,
saved_usd: b.totals.saved_usd,
total_events: b.totals.total_events as u64,
})
})
.collect();
points.sort_by_key(|p| p.date);
signer_points.push(points);
}
finalize(totals, members, model_totals, tool_totals, &signer_points)
}
fn finalize(
mut totals: SavingsTotals,
mut members: Vec<MemberSavings>,
model_totals: HashMap<String, (u64, f64)>,
tool_totals: HashMap<String, u64>,
signer_points: &[Vec<DayPoint>],
) -> TeamSavingsSummary {
totals.saved_usd = round_usd(totals.saved_usd);
members.sort_by_key(|m| std::cmp::Reverse(m.net_saved_tokens));
let mut by_model: Vec<ModelRow> = model_totals
.into_iter()
.map(|(model, (saved_tokens, usd))| ModelRow {
model,
saved_tokens,
saved_usd: round_usd(usd),
})
.collect();
by_model.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
by_model.truncate(MAX_BREAKDOWN_ROWS);
let mut by_tool: Vec<ToolRow> = tool_totals
.into_iter()
.map(|(tool, saved_tokens)| ToolRow { tool, saved_tokens })
.collect();
by_tool.sort_by_key(|r| std::cmp::Reverse(r.saved_tokens));
by_tool.truncate(MAX_BREAKDOWN_ROWS);
let series = build_series(signer_points, Utc::now().date_naive(), SERIES_WINDOW_DAYS);
TeamSavingsSummary {
schema_version: 2,
generated_at: Utc::now().to_rfc3339(),
member_count: members.len(),
totals,
by_member: members,
by_model,
by_tool,
series,
window_days: SERIES_WINDOW_DAYS,
}
}
fn build_series(
signer_points: &[Vec<DayPoint>],
today: NaiveDate,
window_days: u32,
) -> Vec<SeriesPoint> {
if window_days == 0 || signer_points.iter().all(Vec::is_empty) {
return Vec::new();
}
let start = today - Days::new(u64::from(window_days.saturating_sub(1)));
let mut cursor = vec![0usize; signer_points.len()];
let mut carried = vec![(0u64, 0f64, 0u64); signer_points.len()];
let mut out: Vec<SeriesPoint> = Vec::with_capacity(window_days as usize);
let mut day = start;
while day <= today {
let mut net = 0u64;
let mut usd = 0f64;
let mut events = 0u64;
for (si, points) in signer_points.iter().enumerate() {
while cursor[si] < points.len() && points[cursor[si]].date <= day {
let p = points[cursor[si]];
carried[si] = (p.net_saved_tokens, p.saved_usd, p.total_events);
cursor[si] += 1;
}
net = net.saturating_add(carried[si].0);
usd += carried[si].1;
events = events.saturating_add(carried[si].2);
}
out.push(SeriesPoint {
date: day.format("%Y-%m-%d").to_string(),
net_saved_tokens: net,
saved_usd: round_usd(usd),
total_events: events,
});
match day.succ_opt() {
Some(next) => day = next,
None => break,
}
}
out
}
fn read_all_batches(path: &Path) -> Vec<SignedSavingsBatchV1> {
let Ok(content) = std::fs::read_to_string(path) else {
return Vec::new();
};
content
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
serde_json::from_str::<SignedSavingsBatchV1>(line).ok()
})
.collect()
}
fn parse_date(created_at: &str) -> Option<NaiveDate> {
chrono::DateTime::parse_from_rfc3339(created_at)
.ok()
.map(|dt| dt.with_timezone(&Utc).date_naive())
}
fn round_usd(v: f64) -> f64 {
(v * 1_000_000.0).round() / 1_000_000.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::savings_ledger::signed_batch::BatchTotals;
fn batch(signer: &str, net: u64, usd: f64, created_at: &str) -> SignedSavingsBatchV1 {
SignedSavingsBatchV1 {
schema_version: 1,
kind: "lean-ctx.savings-batch".into(),
created_at: created_at.into(),
lean_ctx_version: "test".into(),
agent_id: format!("agent-{signer}"),
period: "all".into(),
first_entry_hash: "genesis".into(),
last_entry_hash: "head".into(),
chain_valid: true,
totals: BatchTotals {
total_events: 1,
saved_tokens: net,
net_saved_tokens: net,
saved_usd: usd,
bounce_tokens: 0,
bounce_events: 0,
tokenizers: vec!["o200k_base".into()],
by_model: vec![("claude-opus".into(), net, usd)],
by_tool: vec![("ctx_read".into(), net)],
},
signer_public_key: Some(signer.into()),
signature: Some("sig".into()),
}
}
fn write_lines(dir: &Path, file: &str, batches: &[SignedSavingsBatchV1]) {
let body = batches
.iter()
.map(|b| serde_json::to_string(b).unwrap())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(dir.join(file), body + "\n").unwrap();
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!(
"leanctx_savings_summary_{tag}_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn day(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
fn points(raw: &[(&str, u64, f64, u64)]) -> Vec<DayPoint> {
raw.iter()
.map(|(d, net, usd, ev)| DayPoint {
date: day(d),
net_saved_tokens: *net,
saved_usd: *usd,
total_events: *ev,
})
.collect()
}
#[test]
fn latest_batch_per_signer_is_not_double_counted() {
let dir = temp_dir("nodouble");
write_lines(
&dir,
"savings_aaaaaaaaaaaaaaaa.jsonl",
&[
batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-08T00:00:00Z"),
],
);
write_lines(
&dir,
"savings_bbbbbbbbbbbbbbbb.jsonl",
&[batch(
"bbbbbbbbbbbbbbbb",
2000,
0.02,
"2026-06-07T00:00:00Z",
)],
);
let s = aggregate(&dir);
assert_eq!(s.schema_version, 2);
assert_eq!(s.member_count, 2);
assert_eq!(s.totals.net_saved_tokens, 5000);
assert_eq!(s.totals.total_events, 2);
assert_eq!(s.by_member[0].net_saved_tokens, 3000);
assert_eq!(s.by_member[1].net_saved_tokens, 2000);
assert_eq!(s.by_member[0].total_events, 1);
assert_eq!(s.by_model[0].model, "claude-opus");
assert_eq!(s.by_model[0].saved_tokens, 5000);
assert_eq!(s.by_tool[0].tool, "ctx_read");
assert_eq!(s.by_tool[0].saved_tokens, 5000);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn empty_or_missing_store_is_zeroed() {
let missing = std::env::temp_dir().join("leanctx_savings_summary_does_not_exist_xyz");
let _ = std::fs::remove_dir_all(&missing);
let s = aggregate(&missing);
assert_eq!(s.member_count, 0);
assert_eq!(s.totals.net_saved_tokens, 0);
assert!(s.by_member.is_empty());
assert!(s.series.is_empty());
assert_eq!(s.window_days, SERIES_WINDOW_DAYS);
}
#[test]
fn non_savings_files_are_ignored() {
let dir = temp_dir("ignore");
std::fs::write(dir.join("audit.jsonl"), "{\"not\":\"a batch\"}\n").unwrap();
std::fs::write(dir.join("README.md"), "hello\n").unwrap();
write_lines(
&dir,
"savings_cccccccccccccccc.jsonl",
&[batch(
"cccccccccccccccc",
700,
0.007,
"2026-06-08T00:00:00Z",
)],
);
let s = aggregate(&dir);
assert_eq!(s.member_count, 1);
assert_eq!(s.totals.net_saved_tokens, 700);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn series_carries_each_signer_snapshot_forward_and_sums() {
let a = points(&[
("2026-06-01", 1000, 0.01, 10),
("2026-06-03", 3000, 0.03, 30),
]);
let b = points(&[("2026-06-02", 2000, 0.02, 20)]);
let series = build_series(&[a, b], day("2026-06-04"), 4);
assert_eq!(series.len(), 4);
assert_eq!(series[0].date, "2026-06-01");
assert_eq!(series[0].net_saved_tokens, 1000);
assert_eq!(series[0].total_events, 10);
assert_eq!(series[1].net_saved_tokens, 3000);
assert_eq!(series[1].total_events, 30);
assert_eq!(series[2].net_saved_tokens, 5000);
assert_eq!(series[3].net_saved_tokens, 5000);
assert_eq!(series[3].total_events, 50);
assert!((series[3].saved_usd - 0.05).abs() < 1e-9);
}
#[test]
fn series_window_clips_to_recent_days_only() {
let a = points(&[("2026-01-01", 5000, 0.5, 100)]);
let series = build_series(&[a], day("2026-06-03"), 3);
assert_eq!(series.len(), 3);
assert_eq!(series[0].date, "2026-06-01");
assert_eq!(series[0].net_saved_tokens, 5000);
assert_eq!(series[2].net_saved_tokens, 5000);
}
#[test]
fn series_is_empty_without_points() {
let series = build_series(&[Vec::new(), Vec::new()], day("2026-06-03"), 30);
assert!(series.is_empty());
}
#[test]
fn member_drilldown_returns_latest_breakdowns_and_own_series() {
let dir = temp_dir("drill");
write_lines(
&dir,
"savings_aaaaaaaaaaaaaaaa.jsonl",
&[
batch("aaaaaaaaaaaaaaaa", 1000, 0.01, "2026-06-01T00:00:00Z"),
batch("aaaaaaaaaaaaaaaa", 3000, 0.03, "2026-06-03T00:00:00Z"),
],
);
write_lines(
&dir,
"savings_bbbbbbbbbbbbbbbb.jsonl",
&[batch(
"bbbbbbbbbbbbbbbb",
9999,
0.99,
"2026-06-02T00:00:00Z",
)],
);
let d = member_drilldown(&dir, "aaaaaaaaaaaaaaaa").expect("drilldown");
assert_eq!(d.signer, "aaaaaaaaaaaaaaaa");
assert_eq!(d.agent_id, "agent-aaaaaaaaaaaaaaaa");
assert_eq!(d.totals.net_saved_tokens, 3000);
assert_eq!(d.last_reported, "2026-06-03T00:00:00Z");
assert_eq!(d.by_model.len(), 1);
assert_eq!(d.by_model[0].model, "claude-opus");
assert_eq!(d.by_model[0].saved_tokens, 3000);
assert_eq!(d.by_tool[0].tool, "ctx_read");
assert_eq!(d.window_days, SERIES_WINDOW_DAYS);
let last = d.series.last().expect("series");
assert_eq!(last.net_saved_tokens, 3000);
assert_eq!(last.total_events, 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn member_drilldown_unknown_signer_is_none() {
let dir = temp_dir("drillmissing");
assert!(member_drilldown(&dir, "cccccccccccccccc").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn signer_prefix_validation_rejects_path_chars() {
assert!(is_valid_signer_prefix("aaaaaaaaaaaaaaaa"));
assert!(is_valid_signer_prefix("AbC123_-"));
assert!(!is_valid_signer_prefix(""));
assert!(!is_valid_signer_prefix("../../etc/passwd"));
assert!(!is_valid_signer_prefix("a/b"));
assert!(!is_valid_signer_prefix("a.b"));
assert!(!is_valid_signer_prefix(&"a".repeat(65)));
}
}