use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::provider::Provider;
use super::error::{CostOpsError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRecord {
#[serde(default = "Uuid::new_v4")]
pub id: Uuid,
pub timestamp: DateTime<Utc>,
pub provider: Provider,
pub model: ModelIdentifier,
pub organization_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cached_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_to_first_token_ms: Option<u64>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: serde_json::Value,
pub ingested_at: DateTime<Utc>,
pub source: IngestionSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelIdentifier {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum IngestionSource {
Api { endpoint: String },
File { path: String },
Webhook { endpoint: String },
Stream { topic: String },
}
impl UsageRecord {
pub fn new(
provider: Provider,
model: ModelIdentifier,
organization_id: String,
prompt_tokens: u64,
completion_tokens: u64,
) -> Self {
let total_tokens = prompt_tokens + completion_tokens;
Self {
id: Uuid::new_v4(),
timestamp: Utc::now(),
provider,
model,
organization_id,
project_id: None,
user_id: None,
prompt_tokens,
completion_tokens,
total_tokens,
cached_tokens: None,
reasoning_tokens: None,
latency_ms: None,
time_to_first_token_ms: None,
tags: Vec::new(),
metadata: serde_json::Value::Null,
ingested_at: Utc::now(),
source: IngestionSource::Api {
endpoint: "default".to_string(),
},
}
}
pub fn validate(&self) -> Result<()> {
if self.total_tokens == 0 {
return Err(CostOpsError::InvalidTokenCount(
"Total tokens cannot be zero".to_string(),
));
}
let calculated_total = self.prompt_tokens + self.completion_tokens;
if calculated_total != self.total_tokens {
return Err(CostOpsError::TokenCountMismatch {
calculated: calculated_total,
reported: self.total_tokens,
});
}
if self.organization_id.is_empty() {
return Err(CostOpsError::MissingOrganizationId);
}
if self.timestamp > Utc::now() {
return Err(CostOpsError::FutureTimestamp);
}
Ok(())
}
pub fn with_project(mut self, project_id: String) -> Self {
self.project_id = Some(project_id);
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_latency(mut self, latency_ms: u64) -> Self {
self.latency_ms = Some(latency_ms);
self
}
pub fn with_cached_tokens(mut self, cached_tokens: u64) -> Self {
self.cached_tokens = Some(cached_tokens);
self
}
}
impl ModelIdentifier {
pub fn new(name: String, context_window: u64) -> Self {
Self {
name,
version: None,
context_window: Some(context_window),
}
}
pub fn with_version(mut self, version: String) -> Self {
self.version = Some(version);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_usage_record_validation() {
let record = UsageRecord::new(
Provider::OpenAI,
ModelIdentifier::new("gpt-4".to_string(), 8192),
"org-123".to_string(),
100,
50,
);
assert!(record.validate().is_ok());
}
#[test]
fn test_token_count_mismatch() {
let mut record = UsageRecord::new(
Provider::OpenAI,
ModelIdentifier::new("gpt-4".to_string(), 8192),
"org-123".to_string(),
100,
50,
);
record.total_tokens = 200;
assert!(matches!(
record.validate(),
Err(CostOpsError::TokenCountMismatch { .. })
));
}
#[test]
fn test_builder_pattern() {
let record = UsageRecord::new(
Provider::OpenAI,
ModelIdentifier::new("gpt-4".to_string(), 8192),
"org-123".to_string(),
100,
50,
)
.with_project("project-456".to_string())
.with_tags(vec!["production".to_string(), "api".to_string()])
.with_latency(250);
assert_eq!(record.project_id, Some("project-456".to_string()));
assert_eq!(record.tags.len(), 2);
assert_eq!(record.latency_ms, Some(250));
}
}