diff --git a/crates/punch-api/src/routes/mod.rs b/crates/punch-api/src/routes/mod.rs index 53ee596..e9f26a1 100644 --- a/crates/punch-api/src/routes/mod.rs +++ b/crates/punch-api/src/routes/mod.rs @@ -15,6 +15,7 @@ pub mod heartbeats; pub mod metrics; pub mod moves; pub mod openai_compat; +pub mod stats; pub mod tenants; pub mod triggers; pub mod troops; @@ -45,5 +46,6 @@ pub fn api_router() -> Router { .merge(communication::router()) .merge(tenants::router()) .merge(moves::router()) + .merge(stats::router()) .merge(docs::router()) } diff --git a/crates/punch-api/src/routes/stats.rs b/crates/punch-api/src/routes/stats.rs new file mode 100644 index 0000000..c4f5e2f --- /dev/null +++ b/crates/punch-api/src/routes/stats.rs @@ -0,0 +1,251 @@ +//! Token usage statistics endpoints — the promoter's ledger. + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::get; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use punch_kernel::SpendPeriod; +use punch_types::FighterId; + +use crate::AppState; + +/// Build the stats routes. +pub fn router() -> Router { + Router::new() + .route("/api/stats", get(get_global_stats)) + .route("/api/stats/fighters/{id}", get(get_fighter_stats)) +} + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct StatsQuery { + /// Time period: "hour", "day", or "month" (default: "day"). + #[serde(default = "default_period")] + period: String, +} + +fn default_period() -> String { + "day".to_string() +} + +fn parse_period(s: &str) -> Result { + match s { + "hour" => Ok(SpendPeriod::Hour), + "day" => Ok(SpendPeriod::Day), + "month" => Ok(SpendPeriod::Month), + other => Err(format!( + "invalid period: {other} (expected hour, day, or month)" + )), + } +} + +#[derive(Serialize)] +struct ErrorResponse { + error: String, +} + +#[derive(Serialize)] +struct ModelBreakdown { + model: String, + input_tokens: u64, + output_tokens: u64, + cost_usd: f64, + request_count: u64, +} + +#[derive(Serialize)] +struct FighterBreakdown { + fighter_id: String, + fighter_name: String, + input_tokens: u64, + output_tokens: u64, + cost_usd: f64, + request_count: u64, +} + +#[derive(Serialize)] +struct GlobalStatsResponse { + period: String, + total_input_tokens: u64, + total_output_tokens: u64, + total_cost_usd: f64, + total_requests: u64, + by_model: Vec, + by_fighter: Vec, +} + +#[derive(Serialize)] +struct FighterStatsResponse { + fighter_id: String, + fighter_name: String, + period: String, + total_input_tokens: u64, + total_output_tokens: u64, + total_cost_usd: f64, + total_requests: u64, + by_model: Vec, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// GET /api/stats — global usage stats across all fighters. +#[instrument(skip_all)] +async fn get_global_stats( + State(state): State, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + let period = parse_period(&query.period) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))?; + + let metering = state.ring.metering(); + + let summary = metering.get_total_summary(period).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + })?; + + let model_breakdown = metering + .get_total_model_breakdown(period) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + })?; + + let fighter_breakdown = metering.get_fighter_breakdown(period).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + })?; + + // Resolve fighter names from the Ring. + let fighters = state.ring.list_fighters(); + let fighter_name_map: std::collections::HashMap = fighters + .iter() + .map(|(id, manifest, _)| (*id, manifest.name.clone())) + .collect(); + + let by_fighter: Vec = fighter_breakdown + .into_iter() + .map(|fb| FighterBreakdown { + fighter_id: fb.fighter_id.to_string(), + fighter_name: fighter_name_map + .get(&fb.fighter_id) + .cloned() + .unwrap_or_else(|| "unknown".to_string()), + input_tokens: fb.input_tokens, + output_tokens: fb.output_tokens, + cost_usd: fb.cost_usd, + request_count: fb.request_count, + }) + .collect(); + + let by_model: Vec = model_breakdown + .into_iter() + .map(|mb| ModelBreakdown { + model: mb.model, + input_tokens: mb.input_tokens, + output_tokens: mb.output_tokens, + cost_usd: mb.cost_usd, + request_count: mb.request_count, + }) + .collect(); + + Ok(Json(GlobalStatsResponse { + period: query.period, + total_input_tokens: summary.total_input_tokens, + total_output_tokens: summary.total_output_tokens, + total_cost_usd: summary.total_cost_usd, + total_requests: summary.event_count, + by_model, + by_fighter, + })) +} + +/// GET /api/stats/fighters/:id — per-fighter usage stats. +#[instrument(skip(state))] +async fn get_fighter_stats( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + let period = parse_period(&query.period) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))?; + + let fighter_id = FighterId(id); + let metering = state.ring.metering(); + + // Look up fighter name. + let fighters = state.ring.list_fighters(); + let fighter_name = fighters + .iter() + .find(|(fid, _, _)| *fid == fighter_id) + .map(|(_, m, _)| m.name.clone()) + .unwrap_or_else(|| "unknown".to_string()); + + let summary = metering + .get_fighter_summary(&fighter_id, period) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + })?; + + let model_breakdown = metering + .get_model_breakdown(&fighter_id, period) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: e.to_string(), + }), + ) + })?; + + let by_model: Vec = model_breakdown + .into_iter() + .map(|mb| ModelBreakdown { + model: mb.model, + input_tokens: mb.input_tokens, + output_tokens: mb.output_tokens, + cost_usd: mb.cost_usd, + request_count: mb.request_count, + }) + .collect(); + + Ok(Json(FighterStatsResponse { + fighter_id: fighter_id.to_string(), + fighter_name, + period: query.period, + total_input_tokens: summary.total_input_tokens, + total_output_tokens: summary.total_output_tokens, + total_cost_usd: summary.total_cost_usd, + total_requests: summary.event_count, + by_model, + })) +} diff --git a/crates/punch-cli/src/cli.rs b/crates/punch-cli/src/cli.rs index 8efb128..a4c95f9 100644 --- a/crates/punch-cli/src/cli.rs +++ b/crates/punch-cli/src/cli.rs @@ -128,6 +128,17 @@ pub enum Commands { native: bool, }, + /// Show token usage and cost statistics + Stats { + /// Time period: hour, day, month (default: day) + #[arg(short, long, default_value = "day")] + period: String, + + /// Show stats for a specific fighter (by name or ID) + #[arg(short, long)] + fighter: Option, + }, + /// Print version information Version, } diff --git a/crates/punch-cli/src/commands/mod.rs b/crates/punch-cli/src/commands/mod.rs index 575c5b8..ce1147e 100644 --- a/crates/punch-cli/src/commands/mod.rs +++ b/crates/punch-cli/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod heartbeat; pub mod init; pub mod moves; pub mod start; +pub mod stats; pub mod status; pub mod trigger; pub mod tui; diff --git a/crates/punch-cli/src/commands/stats.rs b/crates/punch-cli/src/commands/stats.rs new file mode 100644 index 0000000..9a50ef4 --- /dev/null +++ b/crates/punch-cli/src/commands/stats.rs @@ -0,0 +1,252 @@ +//! `punch stats` — Show token usage and cost statistics. + +use super::load_config; + +pub async fn run(period: String, fighter: Option) -> i32 { + // Validate period. + if !["hour", "day", "month"].contains(&period.as_str()) { + eprintln!(" [X] Invalid period: {period}. Expected: hour, day, or month"); + return 1; + } + + let base_url = match daemon_url(None) { + Some(url) => url, + None => { + eprintln!(" [X] Daemon is not running. Start it with: punch start"); + return 1; + } + }; + + let client = reqwest::Client::new(); + + if let Some(ref fighter_ref) = fighter { + // Per-fighter stats. First resolve the fighter ID. + let fighter_id = match resolve_fighter_id(&client, &base_url, fighter_ref).await { + Some(id) => id, + None => { + eprintln!(" [X] Fighter not found: {fighter_ref}"); + return 1; + } + }; + + let url = format!( + "{}/api/stats/fighters/{}?period={}", + base_url, fighter_id, period + ); + match client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(stats) = resp.json::().await { + print_fighter_stats(&stats); + return 0; + } + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + eprintln!(" [X] API error ({status}): {body}"); + } + Err(e) => { + eprintln!(" [X] Request failed: {e}"); + } + } + return 1; + } + + // Global stats. + let url = format!("{}/api/stats?period={}", base_url, period); + match client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(stats) = resp.json::().await { + print_global_stats(&stats); + return 0; + } + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + eprintln!(" [X] API error ({status}): {body}"); + } + Err(e) => { + eprintln!(" [X] Request failed: {e}"); + } + } + 1 +} + +/// Try to read the daemon port from config. Returns the base URL if daemon is reachable. +fn daemon_url(config_path: Option<&str>) -> Option { + let config = load_config(config_path).ok()?; + let url = format!("http://{}", config.api_listen); + let health_url = format!("{}/health", url); + + let url_clone = url.clone(); + let handle = std::thread::spawn(move || { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .ok()?; + client.get(&health_url).send().ok().and_then(|resp| { + if resp.status().is_success() { + Some(url_clone) + } else { + None + } + }) + }); + handle.join().ok().flatten() +} + +/// Resolve a fighter name to its UUID via the API. +async fn resolve_fighter_id( + client: &reqwest::Client, + base_url: &str, + name_or_id: &str, +) -> Option { + // If it looks like a UUID already, return it. + if uuid::Uuid::parse_str(name_or_id).is_ok() { + return Some(name_or_id.to_string()); + } + + // Look up by name via the fighters list endpoint. + let url = format!("{}/api/fighters", base_url); + let resp = client.get(&url).send().await.ok()?; + let fighters: Vec = resp.json().await.ok()?; + + for f in &fighters { + if f["name"].as_str() == Some(name_or_id) { + return f["id"].as_str().map(|s| s.to_string()); + } + } + None +} + +fn format_tokens(tokens: u64) -> String { + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}K", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +fn format_cost(cost: f64) -> String { + if cost < 0.01 { + format!("${:.4}", cost) + } else { + format!("${:.2}", cost) + } +} + +fn print_global_stats(stats: &serde_json::Value) { + let period = stats["period"].as_str().unwrap_or("day"); + let total_in = stats["total_input_tokens"].as_u64().unwrap_or(0); + let total_out = stats["total_output_tokens"].as_u64().unwrap_or(0); + let total_cost = stats["total_cost_usd"].as_f64().unwrap_or(0.0); + let total_reqs = stats["total_requests"].as_u64().unwrap_or(0); + + println!(); + println!(" Token Usage Stats (period: {period})"); + println!(" {}", "=".repeat(60)); + println!(); + println!( + " Total: {} in / {} out | {} tokens | {} | {} requests", + format_tokens(total_in), + format_tokens(total_out), + format_tokens(total_in + total_out), + format_cost(total_cost), + total_reqs + ); + + // By model. + if let Some(models) = stats["by_model"].as_array() + && !models.is_empty() + { + println!(); + println!( + " {:<35} {:>10} {:>10} {:>10} {:>6}", + "MODEL", "INPUT", "OUTPUT", "COST", "REQS" + ); + println!(" {}", "-".repeat(75)); + for m in models { + println!( + " {:<35} {:>10} {:>10} {:>10} {:>6}", + m["model"].as_str().unwrap_or("-"), + format_tokens(m["input_tokens"].as_u64().unwrap_or(0)), + format_tokens(m["output_tokens"].as_u64().unwrap_or(0)), + format_cost(m["cost_usd"].as_f64().unwrap_or(0.0)), + m["request_count"].as_u64().unwrap_or(0), + ); + } + } + + // By fighter. + if let Some(fighters) = stats["by_fighter"].as_array() + && !fighters.is_empty() + { + println!(); + println!( + " {:<24} {:>10} {:>10} {:>10} {:>6}", + "FIGHTER", "INPUT", "OUTPUT", "COST", "REQS" + ); + println!(" {}", "-".repeat(64)); + for f in fighters { + println!( + " {:<24} {:>10} {:>10} {:>10} {:>6}", + f["fighter_name"].as_str().unwrap_or("-"), + format_tokens(f["input_tokens"].as_u64().unwrap_or(0)), + format_tokens(f["output_tokens"].as_u64().unwrap_or(0)), + format_cost(f["cost_usd"].as_f64().unwrap_or(0.0)), + f["request_count"].as_u64().unwrap_or(0), + ); + } + } + + println!(); +} + +fn print_fighter_stats(stats: &serde_json::Value) { + let name = stats["fighter_name"].as_str().unwrap_or("unknown"); + let period = stats["period"].as_str().unwrap_or("day"); + let total_in = stats["total_input_tokens"].as_u64().unwrap_or(0); + let total_out = stats["total_output_tokens"].as_u64().unwrap_or(0); + let total_cost = stats["total_cost_usd"].as_f64().unwrap_or(0.0); + let total_reqs = stats["total_requests"].as_u64().unwrap_or(0); + + println!(); + println!(" Token Usage Stats for \"{name}\" (period: {period})"); + println!(" {}", "=".repeat(60)); + println!(); + println!( + " Total: {} in / {} out | {} tokens | {} | {} requests", + format_tokens(total_in), + format_tokens(total_out), + format_tokens(total_in + total_out), + format_cost(total_cost), + total_reqs + ); + + // By model. + if let Some(models) = stats["by_model"].as_array() + && !models.is_empty() + { + println!(); + println!( + " {:<35} {:>10} {:>10} {:>10} {:>6}", + "MODEL", "INPUT", "OUTPUT", "COST", "REQS" + ); + println!(" {}", "-".repeat(75)); + for m in models { + println!( + " {:<35} {:>10} {:>10} {:>10} {:>6}", + m["model"].as_str().unwrap_or("-"), + format_tokens(m["input_tokens"].as_u64().unwrap_or(0)), + format_tokens(m["output_tokens"].as_u64().unwrap_or(0)), + format_cost(m["cost_usd"].as_f64().unwrap_or(0.0)), + m["request_count"].as_u64().unwrap_or(0), + ); + } + } + + println!(); +} diff --git a/crates/punch-cli/src/main.rs b/crates/punch-cli/src/main.rs index 93eec1e..9383898 100644 --- a/crates/punch-cli/src/main.rs +++ b/crates/punch-cli/src/main.rs @@ -44,6 +44,7 @@ async fn main() { Commands::Channel { command } => commands::channel::run(command, cli.config).await, Commands::Trigger { command } => commands::trigger::run(command).await, Commands::Heartbeat { command } => commands::heartbeat::run(command).await, + Commands::Stats { period, fighter } => commands::stats::run(period, fighter).await, Commands::Config { command } => commands::config::run(command).await, Commands::Tui => commands::tui::run_tui("http://127.0.0.1:3000").await, Commands::Desktop { port, native } => { diff --git a/crates/punch-kernel/src/metering.rs b/crates/punch-kernel/src/metering.rs index 4ce3af6..56cf0d4 100644 --- a/crates/punch-kernel/src/metering.rs +++ b/crates/punch-kernel/src/metering.rs @@ -209,6 +209,53 @@ impl MeteringEngine { let summary = self.memory.get_total_usage_summary(since).await?; Ok(summary.total_cost_usd) } + + /// Get the usage summary for a fighter over a time period. + pub async fn get_fighter_summary( + &self, + fighter_id: &FighterId, + period: SpendPeriod, + ) -> PunchResult { + let since = Utc::now() - period.to_duration(); + self.memory.get_usage_summary(fighter_id, since).await + } + + /// Get the total usage summary across all fighters over a time period. + pub async fn get_total_summary( + &self, + period: SpendPeriod, + ) -> PunchResult { + let since = Utc::now() - period.to_duration(); + self.memory.get_total_usage_summary(since).await + } + + /// Get per-model usage breakdown for a fighter over a time period. + pub async fn get_model_breakdown( + &self, + fighter_id: &FighterId, + period: SpendPeriod, + ) -> PunchResult> { + let since = Utc::now() - period.to_duration(); + self.memory.get_model_breakdown(fighter_id, since).await + } + + /// Get per-model usage breakdown across all fighters over a time period. + pub async fn get_total_model_breakdown( + &self, + period: SpendPeriod, + ) -> PunchResult> { + let since = Utc::now() - period.to_duration(); + self.memory.get_total_model_breakdown(since).await + } + + /// Get per-fighter usage breakdown over a time period. + pub async fn get_fighter_breakdown( + &self, + period: SpendPeriod, + ) -> PunchResult> { + let since = Utc::now() - period.to_duration(); + self.memory.get_fighter_breakdown(since).await + } } // --------------------------------------------------------------------------- diff --git a/crates/punch-memory/src/lib.rs b/crates/punch-memory/src/lib.rs index b4685da..01fec16 100644 --- a/crates/punch-memory/src/lib.rs +++ b/crates/punch-memory/src/lib.rs @@ -32,4 +32,4 @@ pub use knowledge::{KnowledgeEntity, KnowledgeRelation}; pub use memories::MemoryEntry; pub use migrations::{Migration, MigrationEngine, MigrationStatus}; pub use substrate::MemorySubstrate; -pub use usage::{UsageEvent, UsageSummary}; +pub use usage::{FighterUsageBreakdown, ModelUsageBreakdown, UsageEvent, UsageSummary}; diff --git a/crates/punch-memory/src/usage.rs b/crates/punch-memory/src/usage.rs index 26a76e9..4002b33 100644 --- a/crates/punch-memory/src/usage.rs +++ b/crates/punch-memory/src/usage.rs @@ -27,6 +27,26 @@ pub struct UsageSummary { pub event_count: u64, } +/// Per-model usage breakdown row. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelUsageBreakdown { + pub model: String, + pub input_tokens: u64, + pub output_tokens: u64, + pub cost_usd: f64, + pub request_count: u64, +} + +/// Per-fighter usage breakdown row. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FighterUsageBreakdown { + pub fighter_id: FighterId, + pub input_tokens: u64, + pub output_tokens: u64, + pub cost_usd: f64, + pub request_count: u64, +} + impl MemorySubstrate { /// Record a usage event for a fighter. pub async fn record_usage( @@ -95,6 +115,145 @@ impl MemorySubstrate { Ok(result) } + /// Get per-model usage breakdown for a fighter since the given timestamp. + pub async fn get_model_breakdown( + &self, + fighter_id: &FighterId, + since: DateTime, + ) -> PunchResult> { + let fighter_str = fighter_id.to_string(); + let since_str = since.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + let conn = self.conn.lock().await; + + let mut stmt = conn + .prepare( + "SELECT model, + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cost_usd), 0.0), + COUNT(*) + FROM usage_events + WHERE fighter_id = ?1 AND created_at >= ?2 + GROUP BY model + ORDER BY SUM(cost_usd) DESC", + ) + .map_err(|e| PunchError::Memory(format!("failed to prepare model breakdown: {e}")))?; + + let rows = stmt + .query_map(rusqlite::params![fighter_str, since_str], |row| { + Ok(ModelUsageBreakdown { + model: row.get(0)?, + input_tokens: row.get(1)?, + output_tokens: row.get(2)?, + cost_usd: row.get(3)?, + request_count: row.get(4)?, + }) + }) + .map_err(|e| PunchError::Memory(format!("failed to query model breakdown: {e}")))?; + + let mut result = Vec::new(); + for row in rows { + result.push( + row.map_err(|e| PunchError::Memory(format!("failed to read breakdown row: {e}")))?, + ); + } + Ok(result) + } + + /// Get per-model usage breakdown across ALL fighters since the given timestamp. + pub async fn get_total_model_breakdown( + &self, + since: DateTime, + ) -> PunchResult> { + let since_str = since.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + let conn = self.conn.lock().await; + + let mut stmt = conn + .prepare( + "SELECT model, + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cost_usd), 0.0), + COUNT(*) + FROM usage_events + WHERE created_at >= ?1 + GROUP BY model + ORDER BY SUM(cost_usd) DESC", + ) + .map_err(|e| PunchError::Memory(format!("failed to prepare model breakdown: {e}")))?; + + let rows = stmt + .query_map(rusqlite::params![since_str], |row| { + Ok(ModelUsageBreakdown { + model: row.get(0)?, + input_tokens: row.get(1)?, + output_tokens: row.get(2)?, + cost_usd: row.get(3)?, + request_count: row.get(4)?, + }) + }) + .map_err(|e| PunchError::Memory(format!("failed to query model breakdown: {e}")))?; + + let mut result = Vec::new(); + for row in rows { + result.push( + row.map_err(|e| PunchError::Memory(format!("failed to read breakdown row: {e}")))?, + ); + } + Ok(result) + } + + /// Get per-fighter usage breakdown across all fighters since the given timestamp. + pub async fn get_fighter_breakdown( + &self, + since: DateTime, + ) -> PunchResult> { + let since_str = since.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + let conn = self.conn.lock().await; + + let mut stmt = conn + .prepare( + "SELECT fighter_id, + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cost_usd), 0.0), + COUNT(*) + FROM usage_events + WHERE created_at >= ?1 + GROUP BY fighter_id + ORDER BY SUM(cost_usd) DESC", + ) + .map_err(|e| PunchError::Memory(format!("failed to prepare fighter breakdown: {e}")))?; + + let rows = stmt + .query_map(rusqlite::params![since_str], |row| { + let id_str: String = row.get(0)?; + let fighter_id = id_str + .parse::() + .map(FighterId) + .unwrap_or_else(|_| FighterId::new()); + Ok(FighterUsageBreakdown { + fighter_id, + input_tokens: row.get(1)?, + output_tokens: row.get(2)?, + cost_usd: row.get(3)?, + request_count: row.get(4)?, + }) + }) + .map_err(|e| PunchError::Memory(format!("failed to query fighter breakdown: {e}")))?; + + let mut result = Vec::new(); + for row in rows { + result.push( + row.map_err(|e| PunchError::Memory(format!("failed to read breakdown row: {e}")))?, + ); + } + Ok(result) + } + /// Get an aggregated usage summary across ALL fighters since the given timestamp. pub async fn get_total_usage_summary(&self, since: DateTime) -> PunchResult { let since_str = since.format("%Y-%m-%dT%H:%M:%SZ").to_string(); @@ -182,6 +341,104 @@ mod tests { assert!((summary.total_cost_usd - 0.043).abs() < 1e-9); } + #[tokio::test] + async fn test_model_breakdown() { + let substrate = MemorySubstrate::in_memory().unwrap(); + let fid = punch_types::FighterId::new(); + substrate + .save_fighter(&fid, &test_manifest(), FighterStatus::Idle) + .await + .unwrap(); + + substrate + .record_usage(&fid, "claude-sonnet-4-20250514", 1000, 500, 0.015) + .await + .unwrap(); + substrate + .record_usage(&fid, "gpt-4o-mini", 2000, 800, 0.002) + .await + .unwrap(); + substrate + .record_usage(&fid, "claude-sonnet-4-20250514", 3000, 1000, 0.030) + .await + .unwrap(); + + let since = Utc::now() - Duration::hours(1); + let breakdown = substrate.get_model_breakdown(&fid, since).await.unwrap(); + + assert_eq!(breakdown.len(), 2); + // Ordered by cost DESC, so sonnet ($0.045) first, then gpt-4o-mini ($0.002) + assert_eq!(breakdown[0].model, "claude-sonnet-4-20250514"); + assert_eq!(breakdown[0].input_tokens, 4000); + assert_eq!(breakdown[0].output_tokens, 1500); + assert_eq!(breakdown[0].request_count, 2); + assert_eq!(breakdown[1].model, "gpt-4o-mini"); + assert_eq!(breakdown[1].request_count, 1); + } + + #[tokio::test] + async fn test_total_model_breakdown() { + let substrate = MemorySubstrate::in_memory().unwrap(); + let fid1 = punch_types::FighterId::new(); + let fid2 = punch_types::FighterId::new(); + substrate + .save_fighter(&fid1, &test_manifest(), FighterStatus::Idle) + .await + .unwrap(); + substrate + .save_fighter(&fid2, &test_manifest(), FighterStatus::Idle) + .await + .unwrap(); + + substrate + .record_usage(&fid1, "claude-sonnet-4-20250514", 1000, 500, 0.015) + .await + .unwrap(); + substrate + .record_usage(&fid2, "claude-sonnet-4-20250514", 2000, 800, 0.028) + .await + .unwrap(); + + let since = Utc::now() - Duration::hours(1); + let breakdown = substrate.get_total_model_breakdown(since).await.unwrap(); + + assert_eq!(breakdown.len(), 1); + assert_eq!(breakdown[0].input_tokens, 3000); + assert_eq!(breakdown[0].request_count, 2); + } + + #[tokio::test] + async fn test_fighter_breakdown() { + let substrate = MemorySubstrate::in_memory().unwrap(); + let fid1 = punch_types::FighterId::new(); + let fid2 = punch_types::FighterId::new(); + substrate + .save_fighter(&fid1, &test_manifest(), FighterStatus::Idle) + .await + .unwrap(); + substrate + .save_fighter(&fid2, &test_manifest(), FighterStatus::Idle) + .await + .unwrap(); + + substrate + .record_usage(&fid1, "claude-sonnet-4-20250514", 1000, 500, 0.015) + .await + .unwrap(); + substrate + .record_usage(&fid2, "gpt-4o-mini", 5000, 2000, 0.004) + .await + .unwrap(); + + let since = Utc::now() - Duration::hours(1); + let breakdown = substrate.get_fighter_breakdown(since).await.unwrap(); + + assert_eq!(breakdown.len(), 2); + // Ordered by cost DESC: sonnet ($0.015) first + assert_eq!(breakdown[0].fighter_id, fid1); + assert_eq!(breakdown[1].fighter_id, fid2); + } + #[tokio::test] async fn test_usage_summary_empty() { let substrate = MemorySubstrate::in_memory().unwrap();