use std::{
collections::BTreeMap,
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{CostEstimate, Error, Result, estimate_cost};
const RECEIPT_VERSION: u32 = 3;
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsage {
pub input_tokens: u64,
pub cached_input_tokens: u64,
pub thinking_tokens: u64,
pub output_tokens: u64,
}
impl TokenUsage {
fn add_assign(&mut self, other: Self) {
self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
self.cached_input_tokens = self
.cached_input_tokens
.saturating_add(other.cached_input_tokens);
self.thinking_tokens = self.thinking_tokens.saturating_add(other.thinking_tokens);
self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Metering {
Tokens(TokenUsage),
DurationSeconds { seconds: f64 },
Unavailable,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageReceipt {
pub version: u32,
pub id: Uuid,
pub recorded_at: DateTime<Utc>,
pub user_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_operation_id: Option<Uuid>,
pub operation: String,
pub requested_model: String,
pub actual_model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_thread_id: Option<String>,
pub metering: Metering,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<CostEstimate>,
}
impl UsageReceipt {
pub(crate) fn new(
user_id: impl Into<String>,
operation_id: Uuid,
parent_operation_id: Option<Uuid>,
operation: impl Into<String>,
requested_model: impl Into<String>,
actual_model: impl Into<String>,
metering: Metering,
) -> Self {
let actual_model = actual_model.into();
let cost = estimate_cost(&actual_model, &metering);
Self {
version: RECEIPT_VERSION,
id: Uuid::new_v4(),
recorded_at: Utc::now(),
user_id: user_id.into(),
operation_id: Some(operation_id),
parent_operation_id,
operation: operation.into(),
requested_model: requested_model.into(),
actual_model,
provider_request_id: None,
provider_thread_id: None,
metering,
cost,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyUsageKey {
pub user_id: String,
pub model: String,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyUsage {
pub calls: u64,
pub token_calls: u64,
pub duration_calls: u64,
pub unmetered_calls: u64,
pub tokens: TokenUsage,
pub duration_seconds: f64,
pub estimated_cost_usd_nanos: u64,
pub unpriced_calls: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct ReceiptStore {
root: Arc<PathBuf>,
writer: Arc<Mutex<()>>,
}
impl ReceiptStore {
pub(crate) fn open(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
fs::create_dir_all(&root).map_err(|error| receipt_io("create", &root, error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
.map_err(|error| receipt_io("protect", &root, error))?;
}
Ok(Self {
root: Arc::new(root),
writer: Arc::new(Mutex::new(())),
})
}
pub(crate) fn record(&self, receipt: &UsageReceipt) -> Result<()> {
let _guard = self.writer.lock().map_err(|_| {
Error::internal(
"receipt_store_unavailable",
"The usage receipt writer is unavailable.",
)
})?;
let bytes = serde_json::to_vec_pretty(receipt).map_err(|error| {
Error::internal(
"receipt_serialization_failed",
format!("Could not serialize usage receipt: {error}"),
)
})?;
let final_path = self.root.join(format!("{}.json", receipt.id));
let temporary_path = self.root.join(format!(".{}.tmp", receipt.id));
let result = (|| -> std::io::Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&temporary_path)?;
file.write_all(&bytes)?;
file.sync_all()?;
fs::rename(&temporary_path, &final_path)?;
sync_directory(&self.root)?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary_path);
}
result.map_err(|error| receipt_io("write", &final_path, error))
}
pub(crate) fn receipts(&self) -> Result<Vec<UsageReceipt>> {
let mut receipts = Vec::new();
let entries =
fs::read_dir(&*self.root).map_err(|error| receipt_io("list", &self.root, error))?;
for entry in entries {
let entry = entry.map_err(|error| receipt_io("list", &self.root, error))?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let bytes = fs::read(&path).map_err(|error| receipt_io("read", &path, error))?;
let receipt = serde_json::from_slice::<UsageReceipt>(&bytes).map_err(|error| {
Error::internal(
"invalid_usage_receipt",
format!("Invalid usage receipt {}: {error}", path.display()),
)
})?;
receipts.push(receipt);
}
receipts.sort_by_key(|receipt| (receipt.recorded_at, receipt.id));
Ok(receipts)
}
pub(crate) fn daily_usage(
&self,
day: NaiveDate,
) -> Result<BTreeMap<DailyUsageKey, DailyUsage>> {
let mut groups = BTreeMap::new();
for receipt in self.receipts()? {
if receipt.recorded_at.date_naive() != day {
continue;
}
let totals = groups
.entry(DailyUsageKey {
user_id: receipt.user_id,
model: receipt.actual_model,
})
.or_insert_with(DailyUsage::default);
totals.calls = totals.calls.saturating_add(1);
if let Some(cost) = receipt.cost {
totals.estimated_cost_usd_nanos = totals
.estimated_cost_usd_nanos
.saturating_add(cost.usd_nanos);
} else {
totals.unpriced_calls = totals.unpriced_calls.saturating_add(1);
}
match receipt.metering {
Metering::Tokens(tokens) => {
totals.token_calls = totals.token_calls.saturating_add(1);
totals.tokens.add_assign(tokens);
}
Metering::DurationSeconds { seconds } => {
totals.duration_calls = totals.duration_calls.saturating_add(1);
totals.duration_seconds += seconds;
}
Metering::Unavailable => {
totals.unmetered_calls = totals.unmetered_calls.saturating_add(1);
}
}
}
Ok(groups)
}
}
fn receipt_io(operation: &str, path: &Path, error: std::io::Error) -> Error {
Error::internal(
"receipt_io_failed",
format!(
"Could not {operation} usage receipts at {}: {error}",
path.display()
),
)
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> std::io::Result<()> {
std::fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn temporary_store() -> (PathBuf, ReceiptStore) {
let root = std::env::temp_dir().join(format!("kennedy-usage-{}", Uuid::new_v4()));
let store = ReceiptStore::open(&root).unwrap();
(root, store)
}
#[test]
fn one_file_is_written_for_each_call_and_daily_totals_scan_them() {
let (root, store) = temporary_store();
let mut first = UsageReceipt::new(
"user-a",
Uuid::new_v4(),
None,
"search",
"gemini-2.5-flash",
"gemini-2.5-flash",
Metering::Tokens(TokenUsage {
input_tokens: 10,
cached_input_tokens: 4,
thinking_tokens: 3,
output_tokens: 2,
}),
);
first.recorded_at = DateTime::parse_from_rfc3339("2026-07-26T01:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert!(first.operation_id.is_some());
let mut second = first.clone();
second.id = Uuid::new_v4();
second.user_id = "user-b".into();
store.record(&first).unwrap();
store.record(&second).unwrap();
assert_eq!(
fs::read_dir(&root).unwrap().flatten().count(),
2,
"there should be exactly one durable file per call"
);
let totals = store
.daily_usage(NaiveDate::from_ymd_opt(2026, 7, 26).unwrap())
.unwrap();
assert_eq!(totals.len(), 2);
assert_eq!(
totals[&DailyUsageKey {
user_id: "user-a".into(),
model: "gemini-2.5-flash".into(),
}]
.tokens
.cached_input_tokens,
4
);
fs::remove_dir_all(root).unwrap();
}
}