Coverage for llm_dataset_engine/utils/budget_controller.py: 93%
41 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-15 18:04 +0200
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-15 18:04 +0200
1"""
2Budget control and enforcement for LLM costs.
4Implements cost monitoring with threshold warnings and hard limits.
5"""
7from decimal import Decimal
8from typing import Optional
10import structlog
12logger = structlog.get_logger(__name__)
15class BudgetExceededError(Exception):
16 """Raised when budget limit is exceeded."""
18 pass
21class BudgetController:
22 """
23 Controls and enforces budget limits during execution.
25 Follows Single Responsibility: only handles budget management.
26 """
28 def __init__(
29 self,
30 max_budget: Optional[Decimal] = None,
31 warn_at_75: bool = True,
32 warn_at_90: bool = True,
33 fail_on_exceed: bool = True,
34 ):
35 """
36 Initialize budget controller.
38 Args:
39 max_budget: Maximum allowed budget in USD
40 warn_at_75: Warn at 75% of budget
41 warn_at_90: Warn at 90% of budget
42 fail_on_exceed: Raise error if budget exceeded
43 """
44 self.max_budget = max_budget
45 self.warn_at_75 = warn_at_75
46 self.warn_at_90 = warn_at_90
47 self.fail_on_exceed = fail_on_exceed
49 self._warned_75 = False
50 self._warned_90 = False
52 def check_budget(self, current_cost: Decimal) -> None:
53 """
54 Check if budget is within limits.
56 Args:
57 current_cost: Current accumulated cost
59 Raises:
60 BudgetExceededError: If budget exceeded and fail_on_exceed=True
61 """
62 if self.max_budget is None:
63 return
65 usage_ratio = float(current_cost / self.max_budget)
67 # 75% warning
68 if (
69 self.warn_at_75
70 and not self._warned_75
71 and usage_ratio >= 0.75
72 ):
73 logger.warning(
74 f"Budget warning: 75% used "
75 f"(${current_cost:.4f} / ${self.max_budget:.2f})"
76 )
77 self._warned_75 = True
79 # 90% warning
80 if (
81 self.warn_at_90
82 and not self._warned_90
83 and usage_ratio >= 0.90
84 ):
85 logger.warning(
86 f"Budget warning: 90% used "
87 f"(${current_cost:.4f} / ${self.max_budget:.2f})"
88 )
89 self._warned_90 = True
91 # Budget exceeded
92 if current_cost > self.max_budget:
93 error_msg = (
94 f"Budget exceeded: ${current_cost:.4f} > ${self.max_budget:.2f}"
95 )
96 logger.error(error_msg)
98 if self.fail_on_exceed:
99 raise BudgetExceededError(error_msg)
101 def get_remaining(self, current_cost: Decimal) -> Optional[Decimal]:
102 """
103 Get remaining budget.
105 Args:
106 current_cost: Current accumulated cost
108 Returns:
109 Remaining budget or None if no limit
110 """
111 if self.max_budget is None:
112 return None
113 return self.max_budget - current_cost
115 def get_usage_percentage(self, current_cost: Decimal) -> Optional[float]:
116 """
117 Get budget usage as percentage.
119 Args:
120 current_cost: Current accumulated cost
122 Returns:
123 Usage percentage or None if no limit
124 """
125 if self.max_budget is None:
126 return None
127 return float(current_cost / self.max_budget) * 100
129 def can_afford(
130 self, estimated_cost: Decimal, current_cost: Decimal
131 ) -> bool:
132 """
133 Check if estimated additional cost is within budget.
135 Args:
136 estimated_cost: Estimated cost for next operation
137 current_cost: Current accumulated cost
139 Returns:
140 True if within budget
141 """
142 if self.max_budget is None:
143 return True
144 return (current_cost + estimated_cost) <= self.max_budget