llm-cost-guard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_cost_guard/__init__.py +39 -0
- llm_cost_guard/backends/__init__.py +52 -0
- llm_cost_guard/backends/base.py +121 -0
- llm_cost_guard/backends/memory.py +265 -0
- llm_cost_guard/backends/sqlite.py +425 -0
- llm_cost_guard/budget.py +306 -0
- llm_cost_guard/cli.py +464 -0
- llm_cost_guard/clients/__init__.py +11 -0
- llm_cost_guard/clients/anthropic.py +231 -0
- llm_cost_guard/clients/openai.py +262 -0
- llm_cost_guard/exceptions.py +71 -0
- llm_cost_guard/integrations/__init__.py +12 -0
- llm_cost_guard/integrations/cache.py +189 -0
- llm_cost_guard/integrations/langchain.py +257 -0
- llm_cost_guard/models.py +123 -0
- llm_cost_guard/pricing/__init__.py +7 -0
- llm_cost_guard/pricing/anthropic.yaml +88 -0
- llm_cost_guard/pricing/bedrock.yaml +215 -0
- llm_cost_guard/pricing/loader.py +221 -0
- llm_cost_guard/pricing/openai.yaml +148 -0
- llm_cost_guard/pricing/vertex.yaml +133 -0
- llm_cost_guard/providers/__init__.py +69 -0
- llm_cost_guard/providers/anthropic.py +115 -0
- llm_cost_guard/providers/base.py +72 -0
- llm_cost_guard/providers/bedrock.py +135 -0
- llm_cost_guard/providers/openai.py +110 -0
- llm_cost_guard/rate_limit.py +233 -0
- llm_cost_guard/span.py +143 -0
- llm_cost_guard/tokenizers/__init__.py +7 -0
- llm_cost_guard/tokenizers/base.py +207 -0
- llm_cost_guard/tracker.py +718 -0
- llm_cost_guard-0.1.0.dist-info/METADATA +357 -0
- llm_cost_guard-0.1.0.dist-info/RECORD +36 -0
- llm_cost_guard-0.1.0.dist-info/WHEEL +4 -0
- llm_cost_guard-0.1.0.dist-info/entry_points.txt +2 -0
- llm_cost_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
llm_cost_guard/budget.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Budget enforcement for LLM Cost Guard.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Callable, Dict, List, Literal, Optional
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BudgetAction(str, Enum):
|
|
16
|
+
"""Actions to take when a budget is exceeded."""
|
|
17
|
+
|
|
18
|
+
WARN = "warn"
|
|
19
|
+
THROTTLE = "throttle"
|
|
20
|
+
BLOCK = "block"
|
|
21
|
+
CALLBACK = "callback"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
BudgetPeriod = Literal["request", "minute", "hour", "day", "week", "month"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Budget:
|
|
29
|
+
"""Budget configuration."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
limit: float
|
|
33
|
+
period: BudgetPeriod = "day"
|
|
34
|
+
action: BudgetAction = BudgetAction.WARN
|
|
35
|
+
tags: Optional[Dict[str, str]] = None
|
|
36
|
+
warning_threshold: float = 0.8 # Warn at 80%
|
|
37
|
+
callback: Optional[Callable[["Budget", float], None]] = None
|
|
38
|
+
|
|
39
|
+
def matches_tags(self, call_tags: Dict[str, str]) -> bool:
|
|
40
|
+
"""Check if this budget applies to the given tags."""
|
|
41
|
+
if self.tags is None:
|
|
42
|
+
return True
|
|
43
|
+
for key, value in self.tags.items():
|
|
44
|
+
if call_tags.get(key) != value:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BudgetTracker:
|
|
50
|
+
"""Tracks spending against budgets."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, budgets: Optional[List[Budget]] = None):
|
|
53
|
+
self._budgets = budgets or []
|
|
54
|
+
self._spending: Dict[str, float] = {} # budget_name -> current spending
|
|
55
|
+
self._period_start: Dict[str, datetime] = {} # budget_name -> period start
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
self._warning_callbacks: List[Callable[[Budget, float], None]] = []
|
|
58
|
+
self._exceeded_callbacks: List[Callable[[Budget], None]] = []
|
|
59
|
+
|
|
60
|
+
# Initialize budget tracking
|
|
61
|
+
now = datetime.now()
|
|
62
|
+
for budget in self._budgets:
|
|
63
|
+
self._spending[budget.name] = 0.0
|
|
64
|
+
self._period_start[budget.name] = self._get_period_start(budget.period, now)
|
|
65
|
+
|
|
66
|
+
def _get_period_start(self, period: BudgetPeriod, now: datetime) -> datetime:
|
|
67
|
+
"""Get the start of the current period."""
|
|
68
|
+
if period == "request":
|
|
69
|
+
return now
|
|
70
|
+
elif period == "minute":
|
|
71
|
+
return now.replace(second=0, microsecond=0)
|
|
72
|
+
elif period == "hour":
|
|
73
|
+
return now.replace(minute=0, second=0, microsecond=0)
|
|
74
|
+
elif period == "day":
|
|
75
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
76
|
+
elif period == "week":
|
|
77
|
+
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
78
|
+
return start - timedelta(days=now.weekday())
|
|
79
|
+
elif period == "month":
|
|
80
|
+
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
81
|
+
return now
|
|
82
|
+
|
|
83
|
+
def _get_period_duration(self, period: BudgetPeriod) -> timedelta:
|
|
84
|
+
"""Get the duration of a period."""
|
|
85
|
+
durations = {
|
|
86
|
+
"request": timedelta(seconds=0),
|
|
87
|
+
"minute": timedelta(minutes=1),
|
|
88
|
+
"hour": timedelta(hours=1),
|
|
89
|
+
"day": timedelta(days=1),
|
|
90
|
+
"week": timedelta(weeks=1),
|
|
91
|
+
"month": timedelta(days=30), # Approximate
|
|
92
|
+
}
|
|
93
|
+
return durations.get(period, timedelta(days=1))
|
|
94
|
+
|
|
95
|
+
def _reset_if_new_period(self, budget: Budget, now: datetime) -> None:
|
|
96
|
+
"""Reset spending if we've entered a new period."""
|
|
97
|
+
if budget.period == "request":
|
|
98
|
+
self._spending[budget.name] = 0.0
|
|
99
|
+
self._period_start[budget.name] = now
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
period_start = self._period_start.get(budget.name)
|
|
103
|
+
if period_start is None:
|
|
104
|
+
self._period_start[budget.name] = self._get_period_start(budget.period, now)
|
|
105
|
+
self._spending[budget.name] = 0.0
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
period_duration = self._get_period_duration(budget.period)
|
|
109
|
+
if now >= period_start + period_duration:
|
|
110
|
+
self._spending[budget.name] = 0.0
|
|
111
|
+
self._period_start[budget.name] = self._get_period_start(budget.period, now)
|
|
112
|
+
|
|
113
|
+
def add_budget(self, budget: Budget) -> None:
|
|
114
|
+
"""Add a new budget."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
self._budgets.append(budget)
|
|
117
|
+
now = datetime.now()
|
|
118
|
+
self._spending[budget.name] = 0.0
|
|
119
|
+
self._period_start[budget.name] = self._get_period_start(budget.period, now)
|
|
120
|
+
|
|
121
|
+
def remove_budget(self, name: str) -> bool:
|
|
122
|
+
"""Remove a budget by name."""
|
|
123
|
+
with self._lock:
|
|
124
|
+
for i, budget in enumerate(self._budgets):
|
|
125
|
+
if budget.name == name:
|
|
126
|
+
del self._budgets[i]
|
|
127
|
+
del self._spending[name]
|
|
128
|
+
del self._period_start[name]
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def get_budget(self, name: str) -> Optional[Budget]:
|
|
133
|
+
"""Get a budget by name."""
|
|
134
|
+
for budget in self._budgets:
|
|
135
|
+
if budget.name == name:
|
|
136
|
+
return budget
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def get_all_budgets(self) -> List[Budget]:
|
|
140
|
+
"""Get all budgets."""
|
|
141
|
+
return list(self._budgets)
|
|
142
|
+
|
|
143
|
+
def get_spending(self, budget_name: str) -> float:
|
|
144
|
+
"""Get current spending for a budget."""
|
|
145
|
+
with self._lock:
|
|
146
|
+
return self._spending.get(budget_name, 0.0)
|
|
147
|
+
|
|
148
|
+
def get_remaining(self, budget_name: str) -> float:
|
|
149
|
+
"""Get remaining budget."""
|
|
150
|
+
budget = self.get_budget(budget_name)
|
|
151
|
+
if budget is None:
|
|
152
|
+
return float("inf")
|
|
153
|
+
with self._lock:
|
|
154
|
+
return max(0.0, budget.limit - self._spending.get(budget_name, 0.0))
|
|
155
|
+
|
|
156
|
+
def get_utilization(self, budget_name: str) -> float:
|
|
157
|
+
"""Get budget utilization as a percentage (0-100)."""
|
|
158
|
+
budget = self.get_budget(budget_name)
|
|
159
|
+
if budget is None or budget.limit == 0:
|
|
160
|
+
return 0.0
|
|
161
|
+
with self._lock:
|
|
162
|
+
spending = self._spending.get(budget_name, 0.0)
|
|
163
|
+
return (spending / budget.limit) * 100
|
|
164
|
+
|
|
165
|
+
def on_warning(self, callback: Callable[[Budget, float], None]) -> None:
|
|
166
|
+
"""Register a callback for budget warnings."""
|
|
167
|
+
self._warning_callbacks.append(callback)
|
|
168
|
+
|
|
169
|
+
def on_exceeded(self, callback: Callable[[Budget], None]) -> None:
|
|
170
|
+
"""Register a callback for budget exceeded events."""
|
|
171
|
+
self._exceeded_callbacks.append(callback)
|
|
172
|
+
|
|
173
|
+
def check_budget(
|
|
174
|
+
self, cost: float, tags: Optional[Dict[str, str]] = None
|
|
175
|
+
) -> List[tuple[Budget, BudgetAction]]:
|
|
176
|
+
"""
|
|
177
|
+
Check if adding a cost would exceed any budgets.
|
|
178
|
+
Returns list of (budget, action) tuples for budgets that would be exceeded.
|
|
179
|
+
"""
|
|
180
|
+
tags = tags or {}
|
|
181
|
+
exceeded = []
|
|
182
|
+
now = datetime.now()
|
|
183
|
+
|
|
184
|
+
with self._lock:
|
|
185
|
+
for budget in self._budgets:
|
|
186
|
+
if not budget.matches_tags(tags):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
self._reset_if_new_period(budget, now)
|
|
190
|
+
|
|
191
|
+
current = self._spending.get(budget.name, 0.0)
|
|
192
|
+
projected = current + cost
|
|
193
|
+
|
|
194
|
+
if projected > budget.limit:
|
|
195
|
+
exceeded.append((budget, budget.action))
|
|
196
|
+
|
|
197
|
+
return exceeded
|
|
198
|
+
|
|
199
|
+
def record_cost(
|
|
200
|
+
self, cost: float, tags: Optional[Dict[str, str]] = None
|
|
201
|
+
) -> List[tuple[Budget, BudgetAction, float]]:
|
|
202
|
+
"""
|
|
203
|
+
Record a cost against matching budgets.
|
|
204
|
+
Returns list of (budget, action, current_spending) for warnings/exceeded.
|
|
205
|
+
"""
|
|
206
|
+
tags = tags or {}
|
|
207
|
+
actions = []
|
|
208
|
+
now = datetime.now()
|
|
209
|
+
|
|
210
|
+
with self._lock:
|
|
211
|
+
for budget in self._budgets:
|
|
212
|
+
if not budget.matches_tags(tags):
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
self._reset_if_new_period(budget, now)
|
|
216
|
+
|
|
217
|
+
self._spending[budget.name] = self._spending.get(budget.name, 0.0) + cost
|
|
218
|
+
current = self._spending[budget.name]
|
|
219
|
+
|
|
220
|
+
# Check for exceeded
|
|
221
|
+
if current > budget.limit:
|
|
222
|
+
actions.append((budget, budget.action, current))
|
|
223
|
+
# Trigger exceeded callbacks
|
|
224
|
+
for callback in self._exceeded_callbacks:
|
|
225
|
+
try:
|
|
226
|
+
callback(budget)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.error(f"Budget exceeded callback error: {e}")
|
|
229
|
+
# Check for warning
|
|
230
|
+
elif current >= budget.limit * budget.warning_threshold:
|
|
231
|
+
actions.append((budget, BudgetAction.WARN, current))
|
|
232
|
+
# Trigger warning callbacks
|
|
233
|
+
for callback in self._warning_callbacks:
|
|
234
|
+
try:
|
|
235
|
+
callback(budget, current)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"Budget warning callback error: {e}")
|
|
238
|
+
|
|
239
|
+
return actions
|
|
240
|
+
|
|
241
|
+
def reserve(
|
|
242
|
+
self, estimated_cost: float, tags: Optional[Dict[str, str]] = None
|
|
243
|
+
) -> Optional[str]:
|
|
244
|
+
"""
|
|
245
|
+
Reserve budget for a call (pessimistic mode).
|
|
246
|
+
Returns a reservation ID if successful, None if budget would be exceeded.
|
|
247
|
+
"""
|
|
248
|
+
import uuid
|
|
249
|
+
|
|
250
|
+
tags = tags or {}
|
|
251
|
+
|
|
252
|
+
exceeded = self.check_budget(estimated_cost, tags)
|
|
253
|
+
blocking = [b for b, action in exceeded if action == BudgetAction.BLOCK]
|
|
254
|
+
|
|
255
|
+
if blocking:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
# Record the reservation
|
|
259
|
+
self.record_cost(estimated_cost, tags)
|
|
260
|
+
return str(uuid.uuid4())
|
|
261
|
+
|
|
262
|
+
def finalize(
|
|
263
|
+
self,
|
|
264
|
+
reservation_id: str,
|
|
265
|
+
actual_cost: float,
|
|
266
|
+
estimated_cost: float,
|
|
267
|
+
tags: Optional[Dict[str, str]] = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Finalize a reservation with the actual cost.
|
|
271
|
+
Adjusts the difference between estimated and actual.
|
|
272
|
+
"""
|
|
273
|
+
tags = tags or {}
|
|
274
|
+
adjustment = actual_cost - estimated_cost
|
|
275
|
+
|
|
276
|
+
with self._lock:
|
|
277
|
+
for budget in self._budgets:
|
|
278
|
+
if not budget.matches_tags(tags):
|
|
279
|
+
continue
|
|
280
|
+
self._spending[budget.name] = self._spending.get(budget.name, 0.0) + adjustment
|
|
281
|
+
|
|
282
|
+
def release(
|
|
283
|
+
self, reservation_id: str, estimated_cost: float, tags: Optional[Dict[str, str]] = None
|
|
284
|
+
) -> None:
|
|
285
|
+
"""
|
|
286
|
+
Release a reservation (on failure).
|
|
287
|
+
"""
|
|
288
|
+
tags = tags or {}
|
|
289
|
+
|
|
290
|
+
with self._lock:
|
|
291
|
+
for budget in self._budgets:
|
|
292
|
+
if not budget.matches_tags(tags):
|
|
293
|
+
continue
|
|
294
|
+
self._spending[budget.name] = max(
|
|
295
|
+
0.0, self._spending.get(budget.name, 0.0) - estimated_cost
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def reset(self, budget_name: Optional[str] = None) -> None:
|
|
299
|
+
"""Reset spending for a specific budget or all budgets."""
|
|
300
|
+
with self._lock:
|
|
301
|
+
if budget_name:
|
|
302
|
+
if budget_name in self._spending:
|
|
303
|
+
self._spending[budget_name] = 0.0
|
|
304
|
+
else:
|
|
305
|
+
for name in self._spending:
|
|
306
|
+
self._spending[name] = 0.0
|