generated from nhcarrigan/template
1c45507cdf
### Explanation _No response_ ### Issue Closes #102 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #103 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
use chrono::{Datelike, Local, NaiveDate, Weekday};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
/// Represents a single day's cost data
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct DailyCost {
|
|
pub date: String, // ISO date string (YYYY-MM-DD)
|
|
pub input_tokens: u64,
|
|
pub output_tokens: u64,
|
|
pub cost_usd: f64,
|
|
pub messages_sent: u64,
|
|
pub sessions_count: u64,
|
|
}
|
|
|
|
/// Historical cost tracking data
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct CostHistory {
|
|
/// Daily costs indexed by date string (YYYY-MM-DD)
|
|
pub daily_costs: HashMap<String, DailyCost>,
|
|
/// Cost alert thresholds
|
|
pub daily_alert_threshold: Option<f64>,
|
|
pub weekly_alert_threshold: Option<f64>,
|
|
pub monthly_alert_threshold: Option<f64>,
|
|
/// Whether alerts have been triggered today
|
|
pub daily_alert_triggered: bool,
|
|
pub weekly_alert_triggered: bool,
|
|
pub monthly_alert_triggered: bool,
|
|
pub last_alert_reset_date: Option<String>,
|
|
}
|
|
|
|
impl CostHistory {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Get today's date as a string
|
|
fn today_str() -> String {
|
|
Local::now().format("%Y-%m-%d").to_string()
|
|
}
|
|
|
|
/// Get the start of the current week (Monday)
|
|
fn week_start() -> NaiveDate {
|
|
let today = Local::now().date_naive();
|
|
let days_since_monday = today.weekday().num_days_from_monday();
|
|
today - chrono::Duration::days(days_since_monday as i64)
|
|
}
|
|
|
|
/// Get the start of the current month
|
|
fn month_start() -> NaiveDate {
|
|
let today = Local::now().date_naive();
|
|
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today)
|
|
}
|
|
|
|
/// Add cost for today
|
|
pub fn add_cost(&mut self, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
|
|
let today = Self::today_str();
|
|
|
|
// Reset alert flags if it's a new day
|
|
if self.last_alert_reset_date.as_ref() != Some(&today) {
|
|
self.daily_alert_triggered = false;
|
|
// Reset weekly on Monday
|
|
if Local::now().weekday() == Weekday::Mon {
|
|
self.weekly_alert_triggered = false;
|
|
}
|
|
// Reset monthly on the 1st
|
|
if Local::now().day() == 1 {
|
|
self.monthly_alert_triggered = false;
|
|
}
|
|
self.last_alert_reset_date = Some(today.clone());
|
|
}
|
|
|
|
let daily = self.daily_costs.entry(today).or_default();
|
|
daily.input_tokens += input_tokens;
|
|
daily.output_tokens += output_tokens;
|
|
daily.cost_usd += cost_usd;
|
|
daily.messages_sent += 1;
|
|
}
|
|
|
|
/// Increment session count for today
|
|
pub fn increment_sessions(&mut self) {
|
|
let today = Self::today_str();
|
|
let daily = self.daily_costs.entry(today.clone()).or_insert_with(|| DailyCost {
|
|
date: today,
|
|
..Default::default()
|
|
});
|
|
daily.sessions_count += 1;
|
|
}
|
|
|
|
/// Get today's cost
|
|
pub fn get_today_cost(&self) -> f64 {
|
|
self.daily_costs
|
|
.get(&Self::today_str())
|
|
.map(|d| d.cost_usd)
|
|
.unwrap_or(0.0)
|
|
}
|
|
|
|
/// Get this week's cost (Monday to Sunday)
|
|
pub fn get_week_cost(&self) -> f64 {
|
|
let week_start = Self::week_start();
|
|
self.daily_costs
|
|
.values()
|
|
.filter(|d| {
|
|
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
|
.map(|date| date >= week_start)
|
|
.unwrap_or(false)
|
|
})
|
|
.map(|d| d.cost_usd)
|
|
.sum()
|
|
}
|
|
|
|
/// Get this month's cost
|
|
pub fn get_month_cost(&self) -> f64 {
|
|
let month_start = Self::month_start();
|
|
self.daily_costs
|
|
.values()
|
|
.filter(|d| {
|
|
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
|
|
.map(|date| date >= month_start)
|
|
.unwrap_or(false)
|
|
})
|
|
.map(|d| d.cost_usd)
|
|
.sum()
|
|
}
|
|
|
|
/// Get cost summary for a date range
|
|
pub fn get_summary(&self, days: u32) -> CostSummary {
|
|
let today = Local::now().date_naive();
|
|
let start_date = today - chrono::Duration::days(days as i64 - 1);
|
|
|
|
let mut total_input_tokens = 0u64;
|
|
let mut total_output_tokens = 0u64;
|
|
let mut total_cost = 0.0f64;
|
|
let mut total_messages = 0u64;
|
|
let mut total_sessions = 0u64;
|
|
let mut daily_breakdown = Vec::new();
|
|
|
|
for i in 0..days {
|
|
let date = start_date + chrono::Duration::days(i as i64);
|
|
let date_str = date.format("%Y-%m-%d").to_string();
|
|
|
|
if let Some(daily) = self.daily_costs.get(&date_str) {
|
|
total_input_tokens += daily.input_tokens;
|
|
total_output_tokens += daily.output_tokens;
|
|
total_cost += daily.cost_usd;
|
|
total_messages += daily.messages_sent;
|
|
total_sessions += daily.sessions_count;
|
|
daily_breakdown.push(daily.clone());
|
|
} else {
|
|
daily_breakdown.push(DailyCost {
|
|
date: date_str,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
|
|
CostSummary {
|
|
period_days: days,
|
|
total_input_tokens,
|
|
total_output_tokens,
|
|
total_cost,
|
|
total_messages,
|
|
total_sessions,
|
|
average_daily_cost: if days > 0 { total_cost / days as f64 } else { 0.0 },
|
|
daily_breakdown,
|
|
}
|
|
}
|
|
|
|
/// Check if any alert thresholds are exceeded and return which ones
|
|
pub fn check_alerts(&mut self) -> Vec<CostAlert> {
|
|
let mut alerts = Vec::new();
|
|
|
|
if let Some(threshold) = self.daily_alert_threshold {
|
|
let today_cost = self.get_today_cost();
|
|
if today_cost >= threshold && !self.daily_alert_triggered {
|
|
self.daily_alert_triggered = true;
|
|
alerts.push(CostAlert {
|
|
alert_type: AlertType::Daily,
|
|
threshold,
|
|
current_cost: today_cost,
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some(threshold) = self.weekly_alert_threshold {
|
|
let week_cost = self.get_week_cost();
|
|
if week_cost >= threshold && !self.weekly_alert_triggered {
|
|
self.weekly_alert_triggered = true;
|
|
alerts.push(CostAlert {
|
|
alert_type: AlertType::Weekly,
|
|
threshold,
|
|
current_cost: week_cost,
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some(threshold) = self.monthly_alert_threshold {
|
|
let month_cost = self.get_month_cost();
|
|
if month_cost >= threshold && !self.monthly_alert_triggered {
|
|
self.monthly_alert_triggered = true;
|
|
alerts.push(CostAlert {
|
|
alert_type: AlertType::Monthly,
|
|
threshold,
|
|
current_cost: month_cost,
|
|
});
|
|
}
|
|
}
|
|
|
|
alerts
|
|
}
|
|
|
|
/// Set alert thresholds
|
|
pub fn set_alert_thresholds(
|
|
&mut self,
|
|
daily: Option<f64>,
|
|
weekly: Option<f64>,
|
|
monthly: Option<f64>,
|
|
) {
|
|
self.daily_alert_threshold = daily;
|
|
self.weekly_alert_threshold = weekly;
|
|
self.monthly_alert_threshold = monthly;
|
|
}
|
|
|
|
/// Clean up old data (keep last N days)
|
|
#[allow(dead_code)]
|
|
pub fn cleanup_old_data(&mut self, keep_days: u32) {
|
|
let cutoff = Local::now().date_naive() - chrono::Duration::days(keep_days as i64);
|
|
self.daily_costs.retain(|date_str, _| {
|
|
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
|
.map(|date| date >= cutoff)
|
|
.unwrap_or(false)
|
|
});
|
|
}
|
|
|
|
/// Export to CSV format
|
|
pub fn export_csv(&self, days: u32) -> String {
|
|
let summary = self.get_summary(days);
|
|
let mut csv = String::from("Date,Input Tokens,Output Tokens,Cost (USD),Messages,Sessions\n");
|
|
|
|
for daily in &summary.daily_breakdown {
|
|
csv.push_str(&format!(
|
|
"{},{},{},{:.4},{},{}\n",
|
|
daily.date,
|
|
daily.input_tokens,
|
|
daily.output_tokens,
|
|
daily.cost_usd,
|
|
daily.messages_sent,
|
|
daily.sessions_count
|
|
));
|
|
}
|
|
|
|
// Add totals row
|
|
csv.push_str(&format!(
|
|
"TOTAL,{},{},{:.4},{},{}\n",
|
|
summary.total_input_tokens,
|
|
summary.total_output_tokens,
|
|
summary.total_cost,
|
|
summary.total_messages,
|
|
summary.total_sessions
|
|
));
|
|
|
|
csv
|
|
}
|
|
}
|
|
|
|
/// Cost summary for a period
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CostSummary {
|
|
pub period_days: u32,
|
|
pub total_input_tokens: u64,
|
|
pub total_output_tokens: u64,
|
|
pub total_cost: f64,
|
|
pub total_messages: u64,
|
|
pub total_sessions: u64,
|
|
pub average_daily_cost: f64,
|
|
pub daily_breakdown: Vec<DailyCost>,
|
|
}
|
|
|
|
/// Alert types
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum AlertType {
|
|
Daily,
|
|
Weekly,
|
|
Monthly,
|
|
}
|
|
|
|
/// Cost alert notification
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CostAlert {
|
|
pub alert_type: AlertType,
|
|
pub threshold: f64,
|
|
pub current_cost: f64,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_add_cost() {
|
|
let mut history = CostHistory::new();
|
|
history.add_cost(1000, 500, 0.05);
|
|
|
|
let today_cost = history.get_today_cost();
|
|
assert!((today_cost - 0.05).abs() < 0.0001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_accumulate_daily_cost() {
|
|
let mut history = CostHistory::new();
|
|
history.add_cost(1000, 500, 0.05);
|
|
history.add_cost(2000, 1000, 0.10);
|
|
|
|
let today_cost = history.get_today_cost();
|
|
assert!((today_cost - 0.15).abs() < 0.0001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_summary() {
|
|
let mut history = CostHistory::new();
|
|
history.add_cost(1000, 500, 0.05);
|
|
|
|
let summary = history.get_summary(7);
|
|
assert_eq!(summary.period_days, 7);
|
|
assert!((summary.total_cost - 0.05).abs() < 0.0001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_daily_alert() {
|
|
let mut history = CostHistory::new();
|
|
history.set_alert_thresholds(Some(0.10), None, None);
|
|
|
|
history.add_cost(1000, 500, 0.05);
|
|
let alerts = history.check_alerts();
|
|
assert!(alerts.is_empty());
|
|
|
|
history.add_cost(1000, 500, 0.06);
|
|
let alerts = history.check_alerts();
|
|
assert_eq!(alerts.len(), 1);
|
|
assert_eq!(alerts[0].alert_type, AlertType::Daily);
|
|
}
|
|
|
|
#[test]
|
|
fn test_alert_only_triggers_once() {
|
|
let mut history = CostHistory::new();
|
|
history.set_alert_thresholds(Some(0.10), None, None);
|
|
|
|
history.add_cost(1000, 500, 0.15);
|
|
let alerts = history.check_alerts();
|
|
assert_eq!(alerts.len(), 1);
|
|
|
|
// Second check should not trigger again
|
|
let alerts = history.check_alerts();
|
|
assert!(alerts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_export_csv() {
|
|
let mut history = CostHistory::new();
|
|
history.add_cost(1000, 500, 0.05);
|
|
|
|
let csv = history.export_csv(1);
|
|
assert!(csv.contains("Date,Input Tokens"));
|
|
assert!(csv.contains("TOTAL"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_increment_sessions() {
|
|
let mut history = CostHistory::new();
|
|
history.increment_sessions();
|
|
history.increment_sessions();
|
|
|
|
let summary = history.get_summary(1);
|
|
assert_eq!(summary.total_sessions, 2);
|
|
}
|
|
}
|