use chrono::{DateTime, Datelike, Duration, TimeZone, Utc};
use sea_orm::sea_query::OnConflict;
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, Set,
};
use bitrouter_sdk::{BitrouterError, Result};
use crate::metering::db::RequestMetric;
use crate::metering::entities::requests;
#[derive(Debug, Clone, Copy)]
pub enum TimeWindow {
LastMinute,
LastHour,
Today,
ThisWeek,
ThisMonth,
Custom {
start: DateTime<Utc>,
end: DateTime<Utc>,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TokenUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RateMetrics {
pub requests_per_minute: f64,
pub tokens_per_minute: f64,
}
#[derive(Clone)]
pub struct MeteringStore {
db: DatabaseConnection,
}
impl MeteringStore {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn get_spend(&self, api_key_id: &str, window: TimeWindow) -> Result<u64> {
let start = window_start(window).to_rfc3339();
let charges: Vec<i64> = requests::Entity::find()
.select_only()
.column(requests::Column::EstimatedChargeMicroUsd)
.filter(requests::Column::ApiKeyId.eq(api_key_id))
.filter(requests::Column::CreatedAt.gte(start))
.into_tuple()
.all(&self.db)
.await
.map_err(|e| BitrouterError::internal(format!("get_spend: {e}")))?;
Ok(charges.into_iter().map(|c| c.max(0) as u64).sum())
}
pub async fn get_request_count(&self, api_key_id: &str, window: TimeWindow) -> Result<u64> {
let start = window_start(window).to_rfc3339();
requests::Entity::find()
.filter(requests::Column::ApiKeyId.eq(api_key_id))
.filter(requests::Column::CreatedAt.gte(start))
.count(&self.db)
.await
.map_err(|e| BitrouterError::internal(format!("get_request_count: {e}")))
}
pub async fn get_token_usage(
&self,
api_key_id: &str,
model: &str,
window: TimeWindow,
) -> Result<TokenUsage> {
let start = window_start(window).to_rfc3339();
let rows: Vec<(i64, i64)> = requests::Entity::find()
.select_only()
.column(requests::Column::PromptTokens)
.column(requests::Column::CompletionTokens)
.filter(requests::Column::ApiKeyId.eq(api_key_id))
.filter(requests::Column::ModelId.eq(model))
.filter(requests::Column::CreatedAt.gte(start))
.into_tuple()
.all(&self.db)
.await
.map_err(|e| BitrouterError::internal(format!("get_token_usage: {e}")))?;
let prompt: u64 = rows.iter().map(|(p, _)| (*p).max(0) as u64).sum();
let completion: u64 = rows.iter().map(|(_, c)| (*c).max(0) as u64).sum();
Ok(TokenUsage {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt + completion,
})
}
pub async fn get_rate(&self, api_key_id: &str) -> Result<RateMetrics> {
let start = window_start(TimeWindow::LastMinute).to_rfc3339();
let rows: Vec<(i64, i64)> = requests::Entity::find()
.select_only()
.column(requests::Column::PromptTokens)
.column(requests::Column::CompletionTokens)
.filter(requests::Column::ApiKeyId.eq(api_key_id))
.filter(requests::Column::CreatedAt.gte(start))
.into_tuple()
.all(&self.db)
.await
.map_err(|e| BitrouterError::internal(format!("get_rate: {e}")))?;
let tokens: u64 = rows
.iter()
.map(|(p, c)| (*p).max(0) as u64 + (*c).max(0) as u64)
.sum();
Ok(RateMetrics {
requests_per_minute: rows.len() as f64,
tokens_per_minute: tokens as f64,
})
}
pub async fn record_request(&self, record: RequestMetric) -> Result<()> {
let row = requests::ActiveModel {
request_id: Set(record.request_id),
user_id: Set(record.user_id),
api_key_id: Set(record.api_key_id),
model_id: Set(record.model_id),
provider_id: Set(record.provider_id),
prompt_tokens: Set(record.prompt_tokens as i64),
completion_tokens: Set(record.completion_tokens as i64),
reasoning_tokens: Set(record.reasoning_tokens as i64),
cache_read_tokens: Set(record.cache_read_tokens as i64),
cache_write_tokens: Set(record.cache_write_tokens as i64),
estimated_charge_micro_usd: Set(record.estimated_charge_micro_usd),
streamed: Set(record.streamed as i32),
latency_ms: Set(record.latency_ms as i64),
generation_time_ms: Set(record.generation_time_ms as i64),
error: Set(record.error),
created_at: Set(Utc::now().to_rfc3339()),
};
requests::Entity::insert(row)
.on_conflict(
OnConflict::column(requests::Column::RequestId)
.update_columns([
requests::Column::UserId,
requests::Column::ApiKeyId,
requests::Column::ModelId,
requests::Column::ProviderId,
requests::Column::PromptTokens,
requests::Column::CompletionTokens,
requests::Column::ReasoningTokens,
requests::Column::CacheReadTokens,
requests::Column::CacheWriteTokens,
requests::Column::EstimatedChargeMicroUsd,
requests::Column::Streamed,
requests::Column::LatencyMs,
requests::Column::GenerationTimeMs,
requests::Column::Error,
])
.to_owned(),
)
.exec(&self.db)
.await
.map_err(|e| BitrouterError::internal(format!("record_request: {e}")))?;
Ok(())
}
}
fn window_start(window: TimeWindow) -> DateTime<Utc> {
let now = Utc::now();
match window {
TimeWindow::LastMinute => now - Duration::minutes(1),
TimeWindow::LastHour => now - Duration::hours(1),
TimeWindow::Today => Utc
.with_ymd_and_hms(now.year(), now.month(), now.day(), 0, 0, 0)
.single()
.unwrap_or(now),
TimeWindow::ThisWeek => {
let days_from_monday = now.weekday().num_days_from_monday() as i64;
let midnight = Utc
.with_ymd_and_hms(now.year(), now.month(), now.day(), 0, 0, 0)
.single()
.unwrap_or(now);
midnight - Duration::days(days_from_monday)
}
TimeWindow::ThisMonth => Utc
.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
.single()
.unwrap_or(now),
TimeWindow::Custom { start, .. } => start,
}
}