//! Pure token usage aggregation — no I/O, no side effects. //! //! Functions here take slices of `TokenUsageRecord` (already loaded by `io.rs`) //! and compute summaries. Tests cover every branch without touching the filesystem. use crate::agents::token_usage::TokenUsageRecord; use std::collections::HashMap; /// Per-agent cost breakdown entry. #[derive(Debug, Clone, PartialEq)] pub struct AgentTokenCost { pub agent_name: String, pub model: Option, pub input_tokens: u64, pub output_tokens: u64, pub cache_creation_input_tokens: u64, pub cache_read_input_tokens: u64, pub total_cost_usd: f64, } /// Aggregated token cost for a story. #[derive(Debug, Clone, PartialEq)] pub struct TokenCostSummary { pub total_cost_usd: f64, pub agents: Vec, } /// Aggregate token usage records for a single story. /// /// Records for other stories are ignored. The returned `agents` list is sorted /// alphabetically by `agent_name` for deterministic output. Returns a zero-cost /// summary when no records match the given `story_id`. pub fn aggregate_for_story(records: &[TokenUsageRecord], story_id: &str) -> TokenCostSummary { let mut agent_map: HashMap = HashMap::new(); let mut total_cost_usd = 0.0_f64; for record in records.iter().filter(|r| r.story_id == story_id) { total_cost_usd += record.usage.total_cost_usd; let entry = agent_map .entry(record.agent_name.clone()) .or_insert_with(|| AgentTokenCost { agent_name: record.agent_name.clone(), model: record.model.clone(), input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: 0.0, }); entry.input_tokens += record.usage.input_tokens; entry.output_tokens += record.usage.output_tokens; entry.cache_creation_input_tokens += record.usage.cache_creation_input_tokens; entry.cache_read_input_tokens += record.usage.cache_read_input_tokens; entry.total_cost_usd += record.usage.total_cost_usd; } let mut agents: Vec = agent_map.into_values().collect(); agents.sort_by(|a, b| a.agent_name.cmp(&b.agent_name)); TokenCostSummary { total_cost_usd, agents, } } #[cfg(test)] mod tests { use super::*; use crate::agents::TokenUsage; fn make_record(story_id: &str, agent: &str, cost: f64) -> TokenUsageRecord { TokenUsageRecord { story_id: story_id.to_string(), agent_name: agent.to_string(), timestamp: "2024-01-01T00:00:00Z".to_string(), model: None, usage: TokenUsage { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 10, cache_read_input_tokens: 20, total_cost_usd: cost, }, } } #[test] fn aggregate_returns_zero_when_no_records() { let summary = aggregate_for_story(&[], "42_story_foo"); assert_eq!(summary.total_cost_usd, 0.0); assert!(summary.agents.is_empty()); } #[test] fn aggregate_filters_to_story_id() { let records = vec![ make_record("42_story_foo", "coder-1", 1.0), make_record("99_story_other", "coder-1", 5.0), ]; let summary = aggregate_for_story(&records, "42_story_foo"); assert!((summary.total_cost_usd - 1.0).abs() < f64::EPSILON); assert_eq!(summary.agents.len(), 1); } #[test] fn aggregate_sums_tokens_per_agent() { let records = vec![ make_record("42_story_foo", "coder-1", 1.0), make_record("42_story_foo", "coder-1", 2.0), ]; let summary = aggregate_for_story(&records, "42_story_foo"); assert!((summary.total_cost_usd - 3.0).abs() < f64::EPSILON); assert_eq!(summary.agents.len(), 1); assert_eq!(summary.agents[0].input_tokens, 200); assert_eq!(summary.agents[0].output_tokens, 100); assert!((summary.agents[0].total_cost_usd - 3.0).abs() < f64::EPSILON); } #[test] fn aggregate_splits_by_agent() { let records = vec![ make_record("42_story_foo", "coder-1", 1.0), make_record("42_story_foo", "qa", 0.5), ]; let summary = aggregate_for_story(&records, "42_story_foo"); assert!((summary.total_cost_usd - 1.5).abs() < f64::EPSILON); assert_eq!(summary.agents.len(), 2); // sorted alphabetically assert_eq!(summary.agents[0].agent_name, "coder-1"); assert_eq!(summary.agents[1].agent_name, "qa"); } #[test] fn aggregate_sorts_agents_alphabetically() { let records = vec![ make_record("42_story_foo", "z-agent", 1.0), make_record("42_story_foo", "a-agent", 1.0), make_record("42_story_foo", "m-agent", 1.0), ]; let summary = aggregate_for_story(&records, "42_story_foo"); assert_eq!(summary.agents[0].agent_name, "a-agent"); assert_eq!(summary.agents[1].agent_name, "m-agent"); assert_eq!(summary.agents[2].agent_name, "z-agent"); } #[test] fn aggregate_returns_zero_when_no_matching_story() { let records = vec![make_record("99_other", "coder-1", 5.0)]; let summary = aggregate_for_story(&records, "42_story_foo"); assert_eq!(summary.total_cost_usd, 0.0); assert!(summary.agents.is_empty()); } #[test] fn aggregate_preserves_model_from_first_record() { let mut r = make_record("42_story_foo", "coder-1", 1.0); r.model = Some(crate::agents::AgentModel::Sonnet); let summary = aggregate_for_story(&[r], "42_story_foo"); assert_eq!( summary.agents[0].model, Some(crate::agents::AgentModel::Sonnet) ); } }