use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::task::{Context, Poll};
use std::time::{Duration, SystemTime};
use arc_swap::ArcSwap;
use dashmap::DashMap;
use tower::{Layer, Service};
use super::cost::observe_stream_usage;
use super::types::{LlmRequest, LlmRequestKind, LlmResponse};
use crate::client::BoxFuture;
use crate::cost;
use crate::error::{LiterLlmError, Result};
use crate::types::Usage;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetDimension {
Global,
Model(String),
Tenant(String),
User(String),
ApiKey(String),
}
#[derive(Debug, Clone)]
pub enum BudgetVerdict {
Allow,
Reject {
reason: String,
dimension: BudgetDimension,
},
}
pub struct CostRecordContext<'a> {
pub model: &'a str,
pub provider: &'a str,
pub tenant_id: Option<&'a str>,
pub user_id: Option<&'a str>,
pub api_key_id: Option<&'a str>,
pub cost_usd: f64,
pub tokens_in: u64,
pub tokens_out: u64,
pub timestamp: SystemTime,
}
pub struct CostCheckContext<'a> {
pub model: &'a str,
pub provider: &'a str,
pub tenant_id: Option<&'a str>,
pub user_id: Option<&'a str>,
pub api_key_id: Option<&'a str>,
pub timestamp: SystemTime,
}
#[derive(Debug, Clone, Default)]
pub struct BudgetSnapshot {
pub global_spend_usd: f64,
pub per_model: HashMap<String, f64>,
pub per_tenant: HashMap<String, f64>,
pub per_user: HashMap<String, f64>,
pub per_api_key: HashMap<String, f64>,
pub limit_global: Option<f64>,
pub limits_per_user: HashMap<String, f64>,
pub limits_per_api_key: HashMap<String, f64>,
pub limits_per_tenant: HashMap<String, f64>,
}
pub trait BudgetLedger: Send + Sync + 'static {
fn record<'a>(&'a self, ctx: &'a CostRecordContext<'a>) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
fn check<'a>(&'a self, ctx: &'a CostCheckContext<'a>) -> Pin<Box<dyn Future<Output = BudgetVerdict> + Send + 'a>>;
fn snapshot(&self) -> BudgetSnapshot;
}
#[derive(Debug)]
struct WindowEntry {
spend_mc: AtomicU64,
window_start_secs: AtomicU64,
window_secs: u64,
}
impl WindowEntry {
fn new(window: Duration) -> Self {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
spend_mc: AtomicU64::new(0),
window_start_secs: AtomicU64::new(now_secs),
window_secs: window.as_secs(),
}
}
fn spend_usd(&self, now: SystemTime) -> f64 {
let now_secs = now.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs();
let start = self.window_start_secs.load(Ordering::Acquire);
if now_secs.saturating_sub(start) >= self.window_secs {
let old_mc = self.spend_mc.load(Ordering::Acquire);
if self
.window_start_secs
.compare_exchange(start, now_secs, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
self.spend_mc.fetch_sub(old_mc, Ordering::AcqRel);
}
}
microcents_to_usd(self.spend_mc.load(Ordering::Acquire))
}
fn add(&self, usd: f64, now: SystemTime) {
let _ = self.spend_usd(now);
self.spend_mc.fetch_add(usd_to_microcents(usd), Ordering::AcqRel);
}
}
#[derive(Debug, Clone, Default)]
pub struct DimensionLimits {
pub global: Option<f64>,
pub per_model: HashMap<String, f64>,
pub per_tenant: HashMap<String, f64>,
pub per_user: HashMap<String, f64>,
pub per_api_key: HashMap<String, f64>,
}
#[derive(Debug)]
pub struct InMemoryBudgetLedger {
limits: ArcSwap<DimensionLimits>,
window: Duration,
global: Arc<WindowEntry>,
per_model: Arc<DashMap<String, WindowEntry>>,
per_tenant: Arc<DashMap<String, WindowEntry>>,
per_user: Arc<DashMap<String, WindowEntry>>,
per_api_key: Arc<DashMap<String, WindowEntry>>,
max_principal_entries: usize,
}
#[must_use]
pub fn should_hedge<L: BudgetLedger>(
ledger: &L,
ctx: &CostCheckContext<'_>,
estimated_cost_usd: f64,
safety_margin_pct: f64,
) -> bool {
let snap = ledger.snapshot();
let hedge_cost = 2.0 * estimated_cost_usd;
let margin = safety_margin_pct.clamp(0.0, 0.999);
let has_headroom = |spend: f64, limit: f64| -> bool {
let effective_limit = limit * (1.0 - margin);
spend + hedge_cost < effective_limit
};
if let Some(global_limit) = snap.limit_global
&& !has_headroom(snap.global_spend_usd, global_limit)
{
return false;
}
if let Some(user) = ctx.user_id
&& let Some(&user_limit) = snap.limits_per_user.get(user)
{
let user_spend = snap.per_user.get(user).copied().unwrap_or(0.0);
if !has_headroom(user_spend, user_limit) {
return false;
}
}
if let Some(key) = ctx.api_key_id
&& let Some(&key_limit) = snap.limits_per_api_key.get(key)
{
let key_spend = snap.per_api_key.get(key).copied().unwrap_or(0.0);
if !has_headroom(key_spend, key_limit) {
return false;
}
}
if let Some(tenant) = ctx.tenant_id
&& let Some(&tenant_limit) = snap.limits_per_tenant.get(tenant)
{
let tenant_spend = snap.per_tenant.get(tenant).copied().unwrap_or(0.0);
if !has_headroom(tenant_spend, tenant_limit) {
return false;
}
}
true
}
pub(crate) fn provider_of(model: &str) -> &str {
model.split_once('/').map_or("", |(prefix, _)| prefix)
}
pub(crate) fn user_id_of(req: &LlmRequest) -> Option<&str> {
match &req.kind {
LlmRequestKind::Chat(r) | LlmRequestKind::ChatStream(r) => r.user.as_deref(),
LlmRequestKind::Embed(r) => r.user.as_deref(),
_ => None,
}
}
#[cfg_attr(alef, alef(skip))]
pub struct BudgetLedgerLayer<L: BudgetLedger> {
ledger: Arc<L>,
enforcement: Enforcement,
}
impl<L: BudgetLedger> BudgetLedgerLayer<L> {
#[cfg_attr(alef, alef(skip))]
#[must_use]
pub fn new(ledger: Arc<L>, enforcement: Enforcement) -> Self {
Self { ledger, enforcement }
}
}
impl<L: BudgetLedger, S> Layer<S> for BudgetLedgerLayer<L> {
type Service = BudgetLedgerService<L, S>;
fn layer(&self, inner: S) -> Self::Service {
BudgetLedgerService {
inner,
ledger: Arc::clone(&self.ledger),
enforcement: self.enforcement,
}
}
}
#[cfg_attr(alef, alef(skip))]
pub struct BudgetLedgerService<L: BudgetLedger, S> {
inner: S,
ledger: Arc<L>,
enforcement: Enforcement,
}
impl<L: BudgetLedger, S: Clone> Clone for BudgetLedgerService<L, S> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
ledger: Arc::clone(&self.ledger),
enforcement: self.enforcement,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Enforcement {
Hard,
Soft,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BudgetConfig {
pub global_limit: Option<f64>,
pub model_limits: HashMap<String, f64>,
pub enforcement: Enforcement,
}
impl Default for BudgetConfig {
fn default() -> Self {
Self {
global_limit: None,
model_limits: HashMap::new(),
enforcement: Enforcement::Hard,
}
}
}
#[derive(Debug)]
pub struct BudgetState {
global_spend: AtomicU64,
model_spend: DashMap<String, AtomicU64>,
}
impl BudgetState {
#[must_use]
pub fn new() -> Self {
Self {
global_spend: AtomicU64::new(0),
model_spend: DashMap::new(),
}
}
#[must_use]
pub fn global_spend(&self) -> f64 {
microcents_to_usd(self.global_spend.load(Ordering::Relaxed))
}
#[must_use]
pub fn model_spend(&self, model: &str) -> f64 {
self.model_spend
.get(model)
.map(|v| microcents_to_usd(v.load(Ordering::Relaxed)))
.unwrap_or(0.0)
}
pub fn reset(&self) {
self.global_spend.store(0, Ordering::Relaxed);
self.model_spend.clear();
}
fn record(&self, model: &str, usd: f64) {
let mc = usd_to_microcents(usd);
self.global_spend.fetch_add(mc, Ordering::Relaxed);
self.model_spend
.entry(model.to_owned())
.or_insert_with(|| AtomicU64::new(0))
.fetch_add(mc, Ordering::Relaxed);
}
}
#[cfg_attr(alef, alef(skip))]
impl Default for BudgetState {
fn default() -> Self {
Self::new()
}
}
fn usd_to_microcents(usd: f64) -> u64 {
if usd <= 0.0 {
return 0;
}
(usd * 1_000_000.0).round() as u64
}
fn microcents_to_usd(mc: u64) -> f64 {
mc as f64 / 1_000_000.0
}
#[cfg_attr(alef, alef(skip))]
pub struct BudgetLayer {
config: BudgetConfig,
state: Arc<BudgetState>,
}
#[cfg_attr(alef, alef(skip))]
impl BudgetLayer {
#[must_use]
pub fn new(config: BudgetConfig, state: Arc<BudgetState>) -> Self {
Self { config, state }
}
}
impl<S> Layer<S> for BudgetLayer {
type Service = BudgetService<S>;
fn layer(&self, inner: S) -> Self::Service {
BudgetService {
inner,
config: self.config.clone(),
state: Arc::clone(&self.state),
}
}
}
#[cfg_attr(alef, alef(skip))]
pub struct BudgetService<S> {
inner: S,
config: BudgetConfig,
state: Arc<BudgetState>,
}
impl<S: Clone> Clone for BudgetService<S> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
config: self.config.clone(),
state: Arc::clone(&self.state),
}
}
}
impl<S> Service<LlmRequest> for BudgetService<S>
where
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Send + 'static,
S::Future: Send + 'static,
{
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = BoxFuture<'static, Result<LlmResponse>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: LlmRequest) -> Self::Future {
let model = req.model().unwrap_or("unknown").to_owned();
let config = self.config.clone();
let state = Arc::clone(&self.state);
if config.enforcement == Enforcement::Hard
&& let Some(err) = check_budget(&config, &state, &model)
{
return Box::pin(async move { Err(err) });
}
let fut = self.inner.call(req);
Box::pin(async move {
let resp = fut.await?;
match resp {
LlmResponse::ChatStream(stream) => {
let model_for_completion = model.clone();
let state_for_completion = Arc::clone(&state);
let config_for_completion = config.clone();
let wrapped = observe_stream_usage(stream, move |usage| {
record_usage(
&config_for_completion,
&state_for_completion,
&model_for_completion,
usage.as_ref(),
);
});
Ok(LlmResponse::ChatStream(wrapped))
}
other => {
record_usage(&config, &state, &model, other.usage());
Ok(other)
}
}
})
}
}
fn record_usage(config: &BudgetConfig, state: &BudgetState, model: &str, usage: Option<&Usage>) {
let Some(usage) = usage else { return };
let Some(usd) = cost::completion_cost(model, usage.prompt_tokens, usage.completion_tokens) else {
return;
};
state.record(model, usd);
if config.enforcement == Enforcement::Soft {
emit_soft_warnings(config, state, model);
}
}
fn check_budget(config: &BudgetConfig, state: &BudgetState, model: &str) -> Option<LiterLlmError> {
if let Some(limit) = config.global_limit
&& state.global_spend() >= limit
{
return Some(LiterLlmError::BudgetExceeded {
message: format!(
"global budget exceeded: spent ${:.6}, limit ${:.6}",
state.global_spend(),
limit,
),
model: None,
});
}
if let Some(&limit) = config.model_limits.get(model)
&& state.model_spend(model) >= limit
{
return Some(LiterLlmError::BudgetExceeded {
message: format!(
"model {model} budget exceeded: spent ${:.6}, limit ${:.6}",
state.model_spend(model),
limit,
),
model: Some(model.to_owned()),
});
}
None
}
fn emit_soft_warnings(config: &BudgetConfig, state: &BudgetState, model: &str) {
if let Some(limit) = config.global_limit
&& state.global_spend() >= limit
{
tracing::warn!(
spend = state.global_spend(),
limit,
"global budget exceeded (soft enforcement)"
);
}
if let Some(&limit) = config.model_limits.get(model)
&& state.model_spend(model) >= limit
{
tracing::warn!(
model,
spend = state.model_spend(model),
limit,
"model budget exceeded (soft enforcement)"
);
}
}
mod ledger;
mod ledger_service;
#[cfg(test)]
mod ledger_tests;
#[cfg(test)]
mod tests;