mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 07:24:34 +01:00
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""
|
|
Budget model for managing spending limits and cost control
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, Dict, Any
|
|
from enum import Enum
|
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON, ForeignKey, Float
|
|
from sqlalchemy.orm import relationship
|
|
from app.db.database import Base
|
|
|
|
|
|
class BudgetType(str, Enum):
|
|
"""Budget type enumeration"""
|
|
USER = "user"
|
|
API_KEY = "api_key"
|
|
GLOBAL = "global"
|
|
|
|
|
|
class BudgetPeriod(str, Enum):
|
|
"""Budget period types"""
|
|
DAILY = "daily"
|
|
WEEKLY = "weekly"
|
|
MONTHLY = "monthly"
|
|
YEARLY = "yearly"
|
|
CUSTOM = "custom"
|
|
|
|
|
|
class Budget(Base):
|
|
"""Budget model for setting and managing spending limits"""
|
|
|
|
__tablename__ = "budgets"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String, nullable=False) # Human-readable name for the budget
|
|
|
|
# User and API Key relationships
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
user = relationship("User", back_populates="budgets")
|
|
|
|
api_key_id = Column(Integer, ForeignKey("api_keys.id"), nullable=True) # Optional: specific to an API key
|
|
api_key = relationship("APIKey", back_populates="budgets")
|
|
|
|
# Usage tracking relationship
|
|
usage_tracking = relationship("UsageTracking", back_populates="budget", cascade="all, delete-orphan")
|
|
|
|
# Budget limits (in cents)
|
|
limit_cents = Column(Integer, nullable=False) # Maximum spend limit
|
|
warning_threshold_cents = Column(Integer, nullable=True) # Warning threshold (e.g., 80% of limit)
|
|
|
|
# Time period settings
|
|
period_type = Column(String, nullable=False, default="monthly") # daily, weekly, monthly, yearly, custom
|
|
period_start = Column(DateTime, nullable=False) # Start of current period
|
|
period_end = Column(DateTime, nullable=False) # End of current period
|
|
|
|
# Current usage (in cents)
|
|
current_usage_cents = Column(Integer, default=0) # Spent in current period
|
|
|
|
# Budget status
|
|
is_active = Column(Boolean, default=True)
|
|
is_exceeded = Column(Boolean, default=False)
|
|
is_warning_sent = Column(Boolean, default=False)
|
|
|
|
# Enforcement settings
|
|
enforce_hard_limit = Column(Boolean, default=True) # Block requests when limit exceeded
|
|
enforce_warning = Column(Boolean, default=True) # Send warnings at threshold
|
|
|
|
# Allowed resources (optional filters)
|
|
allowed_models = Column(JSON, default=list) # Specific models this budget applies to
|
|
allowed_endpoints = Column(JSON, default=list) # Specific endpoints this budget applies to
|
|
|
|
# Metadata
|
|
description = Column(Text, nullable=True)
|
|
tags = Column(JSON, default=list)
|
|
currency = Column(String, default="USD")
|
|
|
|
# Auto-renewal settings
|
|
auto_renew = Column(Boolean, default=True) # Automatically renew budget for next period
|
|
rollover_unused = Column(Boolean, default=False) # Rollover unused budget to next period
|
|
|
|
# Notification settings
|
|
notification_settings = Column(JSON, default=dict) # Email, webhook, etc.
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
last_reset_at = Column(DateTime, nullable=True) # Last time budget was reset
|
|
|
|
def __repr__(self):
|
|
return f"<Budget(id={self.id}, name='{self.name}', user_id={self.user_id}, limit=${self.limit_cents/100:.2f})>"
|
|
|
|
def to_dict(self):
|
|
"""Convert budget to dictionary for API responses"""
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"user_id": self.user_id,
|
|
"api_key_id": self.api_key_id,
|
|
"limit_cents": self.limit_cents,
|
|
"limit_dollars": self.limit_cents / 100,
|
|
"warning_threshold_cents": self.warning_threshold_cents,
|
|
"warning_threshold_dollars": self.warning_threshold_cents / 100 if self.warning_threshold_cents else None,
|
|
"period_type": self.period_type,
|
|
"period_start": self.period_start.isoformat() if self.period_start else None,
|
|
"period_end": self.period_end.isoformat() if self.period_end else None,
|
|
"current_usage_cents": self.current_usage_cents,
|
|
"current_usage_dollars": self.current_usage_cents / 100,
|
|
"remaining_cents": max(0, self.limit_cents - self.current_usage_cents),
|
|
"remaining_dollars": max(0, (self.limit_cents - self.current_usage_cents) / 100),
|
|
"usage_percentage": (self.current_usage_cents / self.limit_cents * 100) if self.limit_cents > 0 else 0,
|
|
"is_active": self.is_active,
|
|
"is_exceeded": self.is_exceeded,
|
|
"is_warning_sent": self.is_warning_sent,
|
|
"enforce_hard_limit": self.enforce_hard_limit,
|
|
"enforce_warning": self.enforce_warning,
|
|
"allowed_models": self.allowed_models,
|
|
"allowed_endpoints": self.allowed_endpoints,
|
|
"description": self.description,
|
|
"tags": self.tags,
|
|
"currency": self.currency,
|
|
"auto_renew": self.auto_renew,
|
|
"rollover_unused": self.rollover_unused,
|
|
"notification_settings": self.notification_settings,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
"last_reset_at": self.last_reset_at.isoformat() if self.last_reset_at else None
|
|
}
|
|
|
|
def is_in_period(self) -> bool:
|
|
"""Check if current time is within budget period"""
|
|
now = datetime.utcnow()
|
|
return self.period_start <= now <= self.period_end
|
|
|
|
def is_expired(self) -> bool:
|
|
"""Check if budget period has expired"""
|
|
return datetime.utcnow() > self.period_end
|
|
|
|
def can_spend(self, amount_cents: int) -> bool:
|
|
"""Check if spending amount is within budget"""
|
|
if not self.is_active or not self.is_in_period():
|
|
return False
|
|
|
|
if not self.enforce_hard_limit:
|
|
return True
|
|
|
|
return (self.current_usage_cents + amount_cents) <= self.limit_cents
|
|
|
|
def would_exceed_warning(self, amount_cents: int) -> bool:
|
|
"""Check if spending amount would exceed warning threshold"""
|
|
if not self.warning_threshold_cents:
|
|
return False
|
|
|
|
return (self.current_usage_cents + amount_cents) >= self.warning_threshold_cents
|
|
|
|
def add_usage(self, amount_cents: int):
|
|
"""Add usage to current budget"""
|
|
self.current_usage_cents += amount_cents
|
|
|
|
# Check if budget is exceeded
|
|
if self.current_usage_cents >= self.limit_cents:
|
|
self.is_exceeded = True
|
|
|
|
# Check if warning threshold is reached
|
|
if self.warning_threshold_cents and self.current_usage_cents >= self.warning_threshold_cents:
|
|
if not self.is_warning_sent:
|
|
self.is_warning_sent = True
|
|
|
|
self.updated_at = datetime.utcnow()
|
|
|
|
def reset_period(self):
|
|
"""Reset budget for new period"""
|
|
if self.rollover_unused and self.current_usage_cents < self.limit_cents:
|
|
# Rollover unused budget
|
|
unused_amount = self.limit_cents - self.current_usage_cents
|
|
self.limit_cents += unused_amount
|
|
|
|
self.current_usage_cents = 0
|
|
self.is_exceeded = False
|
|
self.is_warning_sent = False
|
|
self.last_reset_at = datetime.utcnow()
|
|
|
|
# Calculate next period
|
|
if self.period_type == "daily":
|
|
self.period_start = self.period_end
|
|
self.period_end = self.period_start + timedelta(days=1)
|
|
elif self.period_type == "weekly":
|
|
self.period_start = self.period_end
|
|
self.period_end = self.period_start + timedelta(weeks=1)
|
|
elif self.period_type == "monthly":
|
|
self.period_start = self.period_end
|
|
# Handle month boundaries properly
|
|
if self.period_start.month == 12:
|
|
next_month = self.period_start.replace(year=self.period_start.year + 1, month=1)
|
|
else:
|
|
next_month = self.period_start.replace(month=self.period_start.month + 1)
|
|
self.period_end = next_month
|
|
elif self.period_type == "yearly":
|
|
self.period_start = self.period_end
|
|
self.period_end = self.period_start.replace(year=self.period_start.year + 1)
|
|
|
|
self.updated_at = datetime.utcnow()
|
|
|
|
def get_period_days_remaining(self) -> int:
|
|
"""Get number of days remaining in current period"""
|
|
if self.is_expired():
|
|
return 0
|
|
return (self.period_end - datetime.utcnow()).days
|
|
|
|
def get_daily_burn_rate(self) -> float:
|
|
"""Get average daily spend rate in current period"""
|
|
if not self.is_in_period():
|
|
return 0.0
|
|
|
|
days_elapsed = (datetime.utcnow() - self.period_start).days
|
|
if days_elapsed == 0:
|
|
days_elapsed = 1 # Avoid division by zero
|
|
|
|
return self.current_usage_cents / days_elapsed / 100 # Return in dollars
|
|
|
|
def get_projected_spend(self) -> float:
|
|
"""Get projected spend for entire period based on current burn rate"""
|
|
daily_burn = self.get_daily_burn_rate()
|
|
total_period_days = (self.period_end - self.period_start).days
|
|
return daily_burn * total_period_days
|
|
|
|
@classmethod
|
|
def create_monthly_budget(
|
|
cls,
|
|
user_id: int,
|
|
name: str,
|
|
limit_dollars: float,
|
|
api_key_id: Optional[int] = None,
|
|
warning_threshold_percentage: float = 0.8
|
|
) -> "Budget":
|
|
"""Create a monthly budget"""
|
|
now = datetime.utcnow()
|
|
# Start of current month
|
|
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
# Start of next month
|
|
if now.month == 12:
|
|
period_end = period_start.replace(year=now.year + 1, month=1)
|
|
else:
|
|
period_end = period_start.replace(month=now.month + 1)
|
|
|
|
limit_cents = int(limit_dollars * 100)
|
|
warning_threshold_cents = int(limit_cents * warning_threshold_percentage)
|
|
|
|
return cls(
|
|
name=name,
|
|
user_id=user_id,
|
|
api_key_id=api_key_id,
|
|
limit_cents=limit_cents,
|
|
warning_threshold_cents=warning_threshold_cents,
|
|
period_type="monthly",
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
is_active=True,
|
|
enforce_hard_limit=True,
|
|
enforce_warning=True,
|
|
auto_renew=True,
|
|
notification_settings={
|
|
"email_on_warning": True,
|
|
"email_on_exceeded": True
|
|
}
|
|
)
|
|
|
|
@classmethod
|
|
def create_daily_budget(
|
|
cls,
|
|
user_id: int,
|
|
name: str,
|
|
limit_dollars: float,
|
|
api_key_id: Optional[int] = None
|
|
) -> "Budget":
|
|
"""Create a daily budget"""
|
|
now = datetime.utcnow()
|
|
period_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
period_end = period_start + timedelta(days=1)
|
|
|
|
limit_cents = int(limit_dollars * 100)
|
|
warning_threshold_cents = int(limit_cents * 0.8) # 80% warning threshold
|
|
|
|
return cls(
|
|
name=name,
|
|
user_id=user_id,
|
|
api_key_id=api_key_id,
|
|
limit_cents=limit_cents,
|
|
warning_threshold_cents=warning_threshold_cents,
|
|
period_type="daily",
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
is_active=True,
|
|
enforce_hard_limit=True,
|
|
enforce_warning=True,
|
|
auto_renew=True
|
|
) |