use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use validator::Validate;
use llm_cost_ops::domain::{ModelIdentifier, Provider};
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct UsageWebhookPayload {
#[serde(default = "Uuid::new_v4")]
pub request_id: Uuid,
pub timestamp: DateTime<Utc>,
#[validate(length(min = 1))]
pub provider: String,
#[validate]
pub model: ModelWebhook,
#[validate(length(min = 1, max = 255))]
pub organization_id: String,
#[validate(length(max = 255))]
pub project_id: Option<String>,
#[validate(length(max = 255))]
pub user_id: Option<String>,
#[validate]
pub usage: TokenUsageWebhook,
pub performance: Option<PerformanceMetrics>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ModelWebhook {
#[validate(length(min = 1, max = 255))]
pub name: String,
#[validate(length(max = 100))]
pub version: Option<String>,
#[validate(range(min = 1))]
pub context_window: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct TokenUsageWebhook {
#[validate(range(min = 0))]
pub prompt_tokens: u64,
#[validate(range(min = 0))]
pub completion_tokens: u64,
#[validate(range(min = 0))]
pub total_tokens: u64,
#[validate(range(min = 0))]
pub cached_tokens: Option<u64>,
#[validate(range(min = 0))]
pub reasoning_tokens: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct PerformanceMetrics {
#[validate(range(min = 0))]
pub latency_ms: Option<u64>,
#[validate(range(min = 0))]
pub time_to_first_token_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct BatchIngestionRequest {
#[serde(default = "Uuid::new_v4")]
pub batch_id: Uuid,
#[validate(length(min = 1, max = 255))]
pub source: String,
#[validate(length(min = 1, max = 1000))]
#[validate]
pub records: Vec<UsageWebhookPayload>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IngestionResponse {
pub request_id: Uuid,
pub status: IngestionStatus,
pub accepted: usize,
pub rejected: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<IngestionError>,
pub processed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IngestionStatus {
Success,
Partial,
Failed,
Queued,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IngestionError {
pub index: Option<usize>,
pub code: String,
pub message: String,
pub field: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamMessage {
pub message_id: String,
pub event_type: StreamEventType,
pub created_at: DateTime<Utc>,
pub payload: UsageWebhookPayload,
#[serde(default)]
pub retry_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StreamEventType {
UsageCreated,
UsageUpdated,
BatchUploaded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IngestionConfig {
pub webhook_enabled: bool,
pub webhook_bind: String,
pub nats_enabled: bool,
pub nats_urls: Vec<String>,
pub nats_subject: String,
pub redis_enabled: bool,
pub redis_url: Option<String>,
pub redis_stream_key: String,
pub buffer_size: usize,
pub max_batch_size: usize,
pub request_timeout_secs: u64,
pub retry: RetryConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_delay_ms: u64,
pub max_delay_ms: u64,
pub backoff_multiplier: f64,
}
impl Default for IngestionConfig {
fn default() -> Self {
Self {
webhook_enabled: true,
webhook_bind: "0.0.0.0:8080".to_string(),
nats_enabled: false,
nats_urls: vec!["nats://localhost:4222".to_string()],
nats_subject: "llm.usage".to_string(),
redis_enabled: false,
redis_url: None,
redis_stream_key: "llm:usage".to_string(),
buffer_size: 10000,
max_batch_size: 1000,
request_timeout_secs: 30,
retry: RetryConfig::default(),
}
}
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_delay_ms: 100,
max_delay_ms: 30000,
backoff_multiplier: 2.0,
}
}
}
impl UsageWebhookPayload {
pub fn to_usage_record(&self) -> llm_cost_ops::UsageRecord {
use llm_cost_ops::domain::IngestionSource;
llm_cost_ops::UsageRecord {
id: self.request_id,
timestamp: self.timestamp,
provider: Provider::parse(&self.provider),
model: ModelIdentifier {
name: self.model.name.clone(),
version: self.model.version.clone(),
context_window: self.model.context_window,
},
organization_id: self.organization_id.clone(),
project_id: self.project_id.clone(),
user_id: self.user_id.clone(),
prompt_tokens: self.usage.prompt_tokens,
completion_tokens: self.usage.completion_tokens,
total_tokens: self.usage.total_tokens,
cached_tokens: self.usage.cached_tokens,
reasoning_tokens: self.usage.reasoning_tokens,
latency_ms: self.performance.as_ref().and_then(|p| p.latency_ms),
time_to_first_token_ms: self
.performance
.as_ref()
.and_then(|p| p.time_to_first_token_ms),
tags: self.tags.clone(),
metadata: serde_json::to_value(&self.metadata).unwrap_or(serde_json::json!({})),
ingested_at: Utc::now(),
source: IngestionSource::Webhook {
endpoint: "/v1/usage".to_string(),
},
}
}
}