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.
Files changed (36) hide show
  1. llm_cost_guard/__init__.py +39 -0
  2. llm_cost_guard/backends/__init__.py +52 -0
  3. llm_cost_guard/backends/base.py +121 -0
  4. llm_cost_guard/backends/memory.py +265 -0
  5. llm_cost_guard/backends/sqlite.py +425 -0
  6. llm_cost_guard/budget.py +306 -0
  7. llm_cost_guard/cli.py +464 -0
  8. llm_cost_guard/clients/__init__.py +11 -0
  9. llm_cost_guard/clients/anthropic.py +231 -0
  10. llm_cost_guard/clients/openai.py +262 -0
  11. llm_cost_guard/exceptions.py +71 -0
  12. llm_cost_guard/integrations/__init__.py +12 -0
  13. llm_cost_guard/integrations/cache.py +189 -0
  14. llm_cost_guard/integrations/langchain.py +257 -0
  15. llm_cost_guard/models.py +123 -0
  16. llm_cost_guard/pricing/__init__.py +7 -0
  17. llm_cost_guard/pricing/anthropic.yaml +88 -0
  18. llm_cost_guard/pricing/bedrock.yaml +215 -0
  19. llm_cost_guard/pricing/loader.py +221 -0
  20. llm_cost_guard/pricing/openai.yaml +148 -0
  21. llm_cost_guard/pricing/vertex.yaml +133 -0
  22. llm_cost_guard/providers/__init__.py +69 -0
  23. llm_cost_guard/providers/anthropic.py +115 -0
  24. llm_cost_guard/providers/base.py +72 -0
  25. llm_cost_guard/providers/bedrock.py +135 -0
  26. llm_cost_guard/providers/openai.py +110 -0
  27. llm_cost_guard/rate_limit.py +233 -0
  28. llm_cost_guard/span.py +143 -0
  29. llm_cost_guard/tokenizers/__init__.py +7 -0
  30. llm_cost_guard/tokenizers/base.py +207 -0
  31. llm_cost_guard/tracker.py +718 -0
  32. llm_cost_guard-0.1.0.dist-info/METADATA +357 -0
  33. llm_cost_guard-0.1.0.dist-info/RECORD +36 -0
  34. llm_cost_guard-0.1.0.dist-info/WHEEL +4 -0
  35. llm_cost_guard-0.1.0.dist-info/entry_points.txt +2 -0
  36. llm_cost_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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