use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenBudgetConfig {
#[serde(default)]
pub period: BudgetPeriod,
pub limit: u64,
#[serde(default = "default_alert_thresholds")]
pub alert_thresholds: Vec<f64>,
#[serde(default = "default_true")]
pub enforce: bool,
#[serde(default)]
pub rollover: bool,
#[serde(default)]
pub burst_allowance: Option<f64>,
#[serde(default = "default_max_tenants")]
pub max_tenants: usize,
}
fn default_alert_thresholds() -> Vec<f64> {
vec![0.80, 0.90, 0.95]
}
fn default_true() -> bool {
true
}
pub fn default_max_tenants() -> usize {
10_000
}
impl Default for TokenBudgetConfig {
fn default() -> Self {
Self {
period: BudgetPeriod::Daily,
limit: 1_000_000, alert_thresholds: default_alert_thresholds(),
enforce: true,
rollover: false,
burst_allowance: None,
max_tenants: default_max_tenants(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum BudgetPeriod {
Hourly,
#[default]
Daily,
Monthly,
Custom {
seconds: u64,
},
}
impl BudgetPeriod {
pub fn as_secs(&self) -> u64 {
match self {
BudgetPeriod::Hourly => 3600,
BudgetPeriod::Daily => 86400,
BudgetPeriod::Monthly => 2_592_000, BudgetPeriod::Custom { seconds } => *seconds,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostAttributionConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub pricing: Vec<ModelPricing>,
#[serde(default = "default_input_cost")]
pub default_input_cost: f64,
#[serde(default = "default_output_cost")]
pub default_output_cost: f64,
#[serde(default = "default_currency")]
pub currency: String,
}
fn default_input_cost() -> f64 {
1.0
}
fn default_output_cost() -> f64 {
2.0
}
fn default_currency() -> String {
"USD".to_string()
}
impl Default for CostAttributionConfig {
fn default() -> Self {
Self {
enabled: false,
pricing: Vec::new(),
default_input_cost: default_input_cost(),
default_output_cost: default_output_cost(),
currency: default_currency(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelPricing {
pub model_pattern: String,
pub input_cost_per_million: f64,
pub output_cost_per_million: f64,
#[serde(default)]
pub currency: Option<String>,
}
impl ModelPricing {
pub fn new(pattern: impl Into<String>, input_cost: f64, output_cost: f64) -> Self {
Self {
model_pattern: pattern.into(),
input_cost_per_million: input_cost,
output_cost_per_million: output_cost,
currency: None,
}
}
pub fn matches(&self, model: &str) -> bool {
if self.model_pattern.contains('*') {
let pattern = &self.model_pattern;
if let Some(inner) = pattern.strip_prefix('*').and_then(|p| p.strip_suffix('*')) {
model.contains(inner)
} else if let Some(suffix) = pattern.strip_prefix('*') {
model.ends_with(suffix)
} else if let Some(prefix) = pattern.strip_suffix('*') {
model.starts_with(prefix)
} else {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.is_empty() {
return true;
}
let mut remaining = model;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
if !remaining.ends_with(part) {
return false;
}
} else {
if let Some(idx) = remaining.find(part) {
remaining = &remaining[idx + part.len()..];
} else {
return false;
}
}
}
true
}
} else {
self.model_pattern == model
}
}
pub fn calculate_cost(&self, input_tokens: u64, output_tokens: u64) -> f64 {
let input_cost = (input_tokens as f64 / 1_000_000.0) * self.input_cost_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * self.output_cost_per_million;
input_cost + output_cost
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BudgetCheckResult {
Allowed {
remaining: u64,
},
Exhausted {
retry_after_secs: u64,
},
Soft {
remaining: i64,
over_by: u64,
},
}
impl BudgetCheckResult {
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allowed { .. } | Self::Soft { .. })
}
pub fn retry_after_secs(&self) -> u64 {
match self {
Self::Exhausted { retry_after_secs } => *retry_after_secs,
_ => 0,
}
}
}
#[derive(Debug, Clone)]
pub struct BudgetAlert {
pub tenant: String,
pub threshold: f64,
pub tokens_used: u64,
pub tokens_limit: u64,
pub period_start: u64,
}
impl BudgetAlert {
pub fn usage_percent(&self) -> f64 {
if self.tokens_limit == 0 {
return 0.0;
}
(self.tokens_used as f64 / self.tokens_limit as f64) * 100.0
}
}
#[derive(Debug, Clone)]
pub struct TenantBudgetStatus {
pub tokens_used: u64,
pub tokens_limit: u64,
pub tokens_remaining: u64,
pub usage_percent: f64,
pub period_start: u64,
pub period_end: u64,
pub exhausted: bool,
}
#[derive(Debug, Clone)]
pub struct CostResult {
pub input_cost: f64,
pub output_cost: f64,
pub total_cost: f64,
pub currency: String,
pub model: String,
pub input_tokens: u64,
pub output_tokens: u64,
}
impl CostResult {
pub fn new(
model: impl Into<String>,
input_tokens: u64,
output_tokens: u64,
input_cost: f64,
output_cost: f64,
currency: impl Into<String>,
) -> Self {
Self {
input_cost,
output_cost,
total_cost: input_cost + output_cost,
currency: currency.into(),
model: model.into(),
input_tokens,
output_tokens,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_budget_period_as_secs() {
assert_eq!(BudgetPeriod::Hourly.as_secs(), 3600);
assert_eq!(BudgetPeriod::Daily.as_secs(), 86400);
assert_eq!(BudgetPeriod::Monthly.as_secs(), 2_592_000);
assert_eq!(BudgetPeriod::Custom { seconds: 7200 }.as_secs(), 7200);
}
#[test]
fn test_model_pricing_exact_match() {
let pricing = ModelPricing::new("gpt-4", 30.0, 60.0);
assert!(pricing.matches("gpt-4"));
assert!(!pricing.matches("gpt-4-turbo"));
assert!(!pricing.matches("gpt-3.5"));
}
#[test]
fn test_model_pricing_prefix_match() {
let pricing = ModelPricing::new("gpt-4*", 30.0, 60.0);
assert!(pricing.matches("gpt-4"));
assert!(pricing.matches("gpt-4-turbo"));
assert!(pricing.matches("gpt-4o"));
assert!(!pricing.matches("gpt-3.5"));
}
#[test]
fn test_model_pricing_suffix_match() {
let pricing = ModelPricing::new("*-turbo", 30.0, 60.0);
assert!(pricing.matches("gpt-4-turbo"));
assert!(pricing.matches("gpt-3.5-turbo"));
assert!(!pricing.matches("gpt-4"));
}
#[test]
fn test_model_pricing_contains_match() {
let pricing = ModelPricing::new("*claude*", 30.0, 60.0);
assert!(pricing.matches("claude-3"));
assert!(pricing.matches("anthropic-claude-3-opus"));
assert!(!pricing.matches("gpt-4"));
}
#[test]
fn test_model_pricing_calculate_cost() {
let pricing = ModelPricing::new("gpt-4", 30.0, 60.0);
let cost = pricing.calculate_cost(1_000_000, 1_000_000);
assert!((cost - 90.0).abs() < 0.001);
let cost = pricing.calculate_cost(1000, 500);
let expected = (1000.0 / 1_000_000.0) * 30.0 + (500.0 / 1_000_000.0) * 60.0;
assert!((cost - expected).abs() < 0.0001);
}
#[test]
fn test_budget_check_result_is_allowed() {
assert!(BudgetCheckResult::Allowed { remaining: 1000 }.is_allowed());
assert!(BudgetCheckResult::Soft {
remaining: -100,
over_by: 100
}
.is_allowed());
assert!(!BudgetCheckResult::Exhausted {
retry_after_secs: 3600
}
.is_allowed());
}
#[test]
fn test_budget_alert_usage_percent() {
let alert = BudgetAlert {
tenant: "test".to_string(),
threshold: 0.80,
tokens_used: 800_000,
tokens_limit: 1_000_000,
period_start: 0,
};
assert!((alert.usage_percent() - 80.0).abs() < 0.001);
}
#[test]
fn test_cost_result_new() {
let result = CostResult::new("gpt-4", 1000, 500, 0.03, 0.03, "USD");
assert_eq!(result.model, "gpt-4");
assert_eq!(result.input_tokens, 1000);
assert_eq!(result.output_tokens, 500);
assert!((result.total_cost - 0.06).abs() < 0.001);
}
#[test]
fn test_token_budget_config_default() {
let config = TokenBudgetConfig::default();
assert_eq!(config.period, BudgetPeriod::Daily);
assert_eq!(config.limit, 1_000_000);
assert!(config.enforce);
assert!(!config.rollover);
assert!(config.burst_allowance.is_none());
assert_eq!(config.alert_thresholds, vec![0.80, 0.90, 0.95]);
}
#[test]
fn test_cost_attribution_config_default() {
let config = CostAttributionConfig::default();
assert!(!config.enabled);
assert!(config.pricing.is_empty());
assert!((config.default_input_cost - 1.0).abs() < 0.001);
assert!((config.default_output_cost - 2.0).abs() < 0.001);
assert_eq!(config.currency, "USD");
}
}