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,39 @@
1
+ """
2
+ LLM Cost Guard - Real-time cost tracking, budget enforcement, and usage analytics for LLM applications.
3
+ """
4
+
5
+ from llm_cost_guard.tracker import CostTracker
6
+ from llm_cost_guard.budget import Budget, BudgetAction
7
+ from llm_cost_guard.rate_limit import RateLimit
8
+ from llm_cost_guard.span import Span
9
+ from llm_cost_guard.models import CostRecord, CostReport, HealthStatus
10
+ from llm_cost_guard.exceptions import (
11
+ LLMCostGuardError,
12
+ BudgetExceededError,
13
+ PricingNotFoundError,
14
+ TokenCountError,
15
+ TrackingUnavailableError,
16
+ RateLimitExceededError,
17
+ )
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ # Core
23
+ "CostTracker",
24
+ "Budget",
25
+ "BudgetAction",
26
+ "RateLimit",
27
+ "Span",
28
+ # Models
29
+ "CostRecord",
30
+ "CostReport",
31
+ "HealthStatus",
32
+ # Exceptions
33
+ "LLMCostGuardError",
34
+ "BudgetExceededError",
35
+ "PricingNotFoundError",
36
+ "TokenCountError",
37
+ "TrackingUnavailableError",
38
+ "RateLimitExceededError",
39
+ ]
@@ -0,0 +1,52 @@
1
+ """
2
+ Storage backends for LLM Cost Guard.
3
+ """
4
+
5
+ from llm_cost_guard.backends.base import Backend
6
+ from llm_cost_guard.backends.memory import MemoryBackend
7
+
8
+ __all__ = ["Backend", "MemoryBackend", "get_backend"]
9
+
10
+
11
+ def get_backend(backend_url: str, **kwargs) -> Backend:
12
+ """
13
+ Create a backend instance from a URL.
14
+
15
+ Supported formats:
16
+ - "memory" or "memory://" - In-memory storage
17
+ - "sqlite:///path/to/db.sqlite" - SQLite database
18
+ - "postgresql://user:pass@host/db" - PostgreSQL database
19
+ - "redis://host:port/db" - Redis
20
+ - "dynamodb://table-name" - DynamoDB
21
+
22
+ Args:
23
+ backend_url: Backend connection URL
24
+ **kwargs: Additional backend-specific options
25
+
26
+ Returns:
27
+ Backend instance
28
+ """
29
+ if backend_url in ("memory", "memory://"):
30
+ return MemoryBackend(**kwargs)
31
+
32
+ if backend_url.startswith("sqlite:"):
33
+ from llm_cost_guard.backends.sqlite import SQLiteBackend
34
+
35
+ return SQLiteBackend(backend_url, **kwargs)
36
+
37
+ if backend_url.startswith("postgresql://") or backend_url.startswith("postgres://"):
38
+ from llm_cost_guard.backends.postgres import PostgresBackend
39
+
40
+ return PostgresBackend(backend_url, **kwargs)
41
+
42
+ if backend_url.startswith("redis://"):
43
+ from llm_cost_guard.backends.redis import RedisBackend
44
+
45
+ return RedisBackend(backend_url, **kwargs)
46
+
47
+ if backend_url.startswith("dynamodb://"):
48
+ from llm_cost_guard.backends.dynamodb import DynamoDBBackend
49
+
50
+ return DynamoDBBackend(backend_url, **kwargs)
51
+
52
+ raise ValueError(f"Unsupported backend URL: {backend_url}")
@@ -0,0 +1,121 @@
1
+ """
2
+ Base backend interface for LLM Cost Guard.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from datetime import datetime
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from llm_cost_guard.models import CostRecord, CostReport
10
+
11
+
12
+ class Backend(ABC):
13
+ """Abstract base class for storage backends."""
14
+
15
+ @abstractmethod
16
+ def save_record(self, record: CostRecord) -> None:
17
+ """Save a cost record."""
18
+ pass
19
+
20
+ @abstractmethod
21
+ def save_records(self, records: List[CostRecord]) -> None:
22
+ """Save multiple cost records."""
23
+ pass
24
+
25
+ @abstractmethod
26
+ def get_records(
27
+ self,
28
+ start_date: Optional[datetime] = None,
29
+ end_date: Optional[datetime] = None,
30
+ tags: Optional[Dict[str, str]] = None,
31
+ limit: Optional[int] = None,
32
+ offset: int = 0,
33
+ ) -> List[CostRecord]:
34
+ """
35
+ Retrieve cost records with optional filters.
36
+
37
+ Args:
38
+ start_date: Filter records after this date
39
+ end_date: Filter records before this date
40
+ tags: Filter by tag key-value pairs
41
+ limit: Maximum number of records to return
42
+ offset: Number of records to skip
43
+
44
+ Returns:
45
+ List of matching CostRecord objects
46
+ """
47
+ pass
48
+
49
+ @abstractmethod
50
+ def get_total_cost(
51
+ self,
52
+ start_date: Optional[datetime] = None,
53
+ end_date: Optional[datetime] = None,
54
+ tags: Optional[Dict[str, str]] = None,
55
+ ) -> float:
56
+ """Get total cost for the given filters."""
57
+ pass
58
+
59
+ @abstractmethod
60
+ def get_aggregated_costs(
61
+ self,
62
+ start_date: Optional[datetime] = None,
63
+ end_date: Optional[datetime] = None,
64
+ tags: Optional[Dict[str, str]] = None,
65
+ group_by: Optional[List[str]] = None,
66
+ ) -> Dict[str, Any]:
67
+ """
68
+ Get aggregated costs grouped by specified fields.
69
+
70
+ Args:
71
+ start_date: Filter records after this date
72
+ end_date: Filter records before this date
73
+ tags: Filter by tag key-value pairs
74
+ group_by: Fields to group by (e.g., ["provider", "model", "team"])
75
+
76
+ Returns:
77
+ Dictionary with grouped cost data
78
+ """
79
+ pass
80
+
81
+ @abstractmethod
82
+ def get_report(
83
+ self,
84
+ start_date: Optional[datetime] = None,
85
+ end_date: Optional[datetime] = None,
86
+ tags: Optional[Dict[str, str]] = None,
87
+ group_by: Optional[List[str]] = None,
88
+ ) -> CostReport:
89
+ """Generate a cost report."""
90
+ pass
91
+
92
+ @abstractmethod
93
+ def delete_records(
94
+ self,
95
+ start_date: Optional[datetime] = None,
96
+ end_date: Optional[datetime] = None,
97
+ tags: Optional[Dict[str, str]] = None,
98
+ ) -> int:
99
+ """
100
+ Delete records matching the filters.
101
+
102
+ Returns:
103
+ Number of records deleted
104
+ """
105
+ pass
106
+
107
+ @abstractmethod
108
+ def health_check(self) -> bool:
109
+ """Check if the backend is healthy and connected."""
110
+ pass
111
+
112
+ @abstractmethod
113
+ def close(self) -> None:
114
+ """Close the backend connection."""
115
+ pass
116
+
117
+ def __enter__(self) -> "Backend":
118
+ return self
119
+
120
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
121
+ self.close()
@@ -0,0 +1,265 @@
1
+ """
2
+ In-memory storage backend for LLM Cost Guard.
3
+ """
4
+
5
+ from collections import defaultdict
6
+ from datetime import datetime
7
+ from typing import Any, Dict, List, Optional
8
+ import threading
9
+
10
+ from llm_cost_guard.backends.base import Backend
11
+ from llm_cost_guard.models import CostRecord, CostReport
12
+
13
+
14
+ class MemoryBackend(Backend):
15
+ """Thread-safe in-memory storage backend."""
16
+
17
+ def __init__(self, max_records: int = 100000, **kwargs):
18
+ """
19
+ Initialize the memory backend.
20
+
21
+ Args:
22
+ max_records: Maximum number of records to keep in memory
23
+ """
24
+ self._records: List[CostRecord] = []
25
+ self._max_records = max_records
26
+ self._lock = threading.RLock()
27
+
28
+ def save_record(self, record: CostRecord) -> None:
29
+ """Save a cost record."""
30
+ with self._lock:
31
+ self._records.append(record)
32
+ # Evict old records if we exceed the limit
33
+ if len(self._records) > self._max_records:
34
+ # Remove oldest 10% of records
35
+ evict_count = self._max_records // 10
36
+ self._records = self._records[evict_count:]
37
+
38
+ def save_records(self, records: List[CostRecord]) -> None:
39
+ """Save multiple cost records."""
40
+ with self._lock:
41
+ self._records.extend(records)
42
+ # Evict old records if we exceed the limit
43
+ if len(self._records) > self._max_records:
44
+ evict_count = len(self._records) - self._max_records
45
+ self._records = self._records[evict_count:]
46
+
47
+ def _matches_filters(
48
+ self,
49
+ record: CostRecord,
50
+ start_date: Optional[datetime],
51
+ end_date: Optional[datetime],
52
+ tags: Optional[Dict[str, str]],
53
+ ) -> bool:
54
+ """Check if a record matches the given filters."""
55
+ if start_date and record.timestamp < start_date:
56
+ return False
57
+ if end_date and record.timestamp > end_date:
58
+ return False
59
+ if tags:
60
+ for key, value in tags.items():
61
+ if record.tags.get(key) != value:
62
+ return False
63
+ return True
64
+
65
+ def get_records(
66
+ self,
67
+ start_date: Optional[datetime] = None,
68
+ end_date: Optional[datetime] = None,
69
+ tags: Optional[Dict[str, str]] = None,
70
+ limit: Optional[int] = None,
71
+ offset: int = 0,
72
+ ) -> List[CostRecord]:
73
+ """Retrieve cost records with optional filters."""
74
+ with self._lock:
75
+ filtered = [
76
+ r for r in self._records if self._matches_filters(r, start_date, end_date, tags)
77
+ ]
78
+
79
+ # Sort by timestamp descending (most recent first)
80
+ filtered.sort(key=lambda r: r.timestamp, reverse=True)
81
+
82
+ # Apply offset and limit
83
+ if offset:
84
+ filtered = filtered[offset:]
85
+ if limit:
86
+ filtered = filtered[:limit]
87
+
88
+ return filtered
89
+
90
+ def get_total_cost(
91
+ self,
92
+ start_date: Optional[datetime] = None,
93
+ end_date: Optional[datetime] = None,
94
+ tags: Optional[Dict[str, str]] = None,
95
+ ) -> float:
96
+ """Get total cost for the given filters."""
97
+ with self._lock:
98
+ total = 0.0
99
+ for record in self._records:
100
+ if self._matches_filters(record, start_date, end_date, tags):
101
+ total += record.total_cost
102
+ return total
103
+
104
+ def get_aggregated_costs(
105
+ self,
106
+ start_date: Optional[datetime] = None,
107
+ end_date: Optional[datetime] = None,
108
+ tags: Optional[Dict[str, str]] = None,
109
+ group_by: Optional[List[str]] = None,
110
+ ) -> Dict[str, Any]:
111
+ """Get aggregated costs grouped by specified fields."""
112
+ with self._lock:
113
+ if not group_by:
114
+ # Return overall totals
115
+ total_cost = 0.0
116
+ total_calls = 0
117
+ total_input_tokens = 0
118
+ total_output_tokens = 0
119
+
120
+ for record in self._records:
121
+ if self._matches_filters(record, start_date, end_date, tags):
122
+ total_cost += record.total_cost
123
+ total_calls += 1
124
+ total_input_tokens += record.input_tokens
125
+ total_output_tokens += record.output_tokens
126
+
127
+ return {
128
+ "total_cost": total_cost,
129
+ "total_calls": total_calls,
130
+ "total_input_tokens": total_input_tokens,
131
+ "total_output_tokens": total_output_tokens,
132
+ }
133
+
134
+ # Group by specified fields
135
+ groups: Dict[tuple, Dict[str, Any]] = defaultdict(
136
+ lambda: {
137
+ "cost": 0.0,
138
+ "calls": 0,
139
+ "input_tokens": 0,
140
+ "output_tokens": 0,
141
+ }
142
+ )
143
+
144
+ for record in self._records:
145
+ if not self._matches_filters(record, start_date, end_date, tags):
146
+ continue
147
+
148
+ # Build group key
149
+ key_parts = []
150
+ for field in group_by:
151
+ if field == "provider":
152
+ key_parts.append(record.provider)
153
+ elif field == "model":
154
+ key_parts.append(record.model)
155
+ elif field in record.tags:
156
+ key_parts.append(record.tags[field])
157
+ else:
158
+ key_parts.append("unknown")
159
+
160
+ key = tuple(key_parts)
161
+ groups[key]["cost"] += record.total_cost
162
+ groups[key]["calls"] += 1
163
+ groups[key]["input_tokens"] += record.input_tokens
164
+ groups[key]["output_tokens"] += record.output_tokens
165
+
166
+ # Convert to list format
167
+ result = []
168
+ for key, data in groups.items():
169
+ row = dict(zip(group_by, key))
170
+ row.update(data)
171
+ result.append(row)
172
+
173
+ # Sort by cost descending
174
+ result.sort(key=lambda x: x["cost"], reverse=True)
175
+
176
+ return {"groups": result, "group_by": group_by}
177
+
178
+ def get_report(
179
+ self,
180
+ start_date: Optional[datetime] = None,
181
+ end_date: Optional[datetime] = None,
182
+ tags: Optional[Dict[str, str]] = None,
183
+ group_by: Optional[List[str]] = None,
184
+ ) -> CostReport:
185
+ """Generate a cost report."""
186
+ with self._lock:
187
+ records = self.get_records(start_date, end_date, tags)
188
+
189
+ total_cost = 0.0
190
+ total_input_tokens = 0
191
+ total_output_tokens = 0
192
+ successful_calls = 0
193
+ failed_calls = 0
194
+ cache_hits = 0
195
+ cache_savings = 0.0
196
+
197
+ for record in records:
198
+ total_cost += record.total_cost
199
+ total_input_tokens += record.input_tokens
200
+ total_output_tokens += record.output_tokens
201
+
202
+ if record.success:
203
+ successful_calls += 1
204
+ else:
205
+ failed_calls += 1
206
+
207
+ if record.cached:
208
+ cache_hits += 1
209
+ cache_savings += record.cache_savings
210
+
211
+ grouped_data = {}
212
+ if group_by:
213
+ agg = self.get_aggregated_costs(start_date, end_date, tags, group_by)
214
+ grouped_data = agg.get("groups", [])
215
+
216
+ return CostReport(
217
+ start_date=start_date,
218
+ end_date=end_date,
219
+ total_cost=total_cost,
220
+ total_input_tokens=total_input_tokens,
221
+ total_output_tokens=total_output_tokens,
222
+ total_calls=len(records),
223
+ successful_calls=successful_calls,
224
+ failed_calls=failed_calls,
225
+ cache_hits=cache_hits,
226
+ cache_savings=cache_savings,
227
+ effective_cost=total_cost - cache_savings,
228
+ records=records,
229
+ grouped_data={"groups": grouped_data} if grouped_data else {},
230
+ )
231
+
232
+ def delete_records(
233
+ self,
234
+ start_date: Optional[datetime] = None,
235
+ end_date: Optional[datetime] = None,
236
+ tags: Optional[Dict[str, str]] = None,
237
+ ) -> int:
238
+ """Delete records matching the filters."""
239
+ with self._lock:
240
+ initial_count = len(self._records)
241
+ self._records = [
242
+ r
243
+ for r in self._records
244
+ if not self._matches_filters(r, start_date, end_date, tags)
245
+ ]
246
+ return initial_count - len(self._records)
247
+
248
+ def health_check(self) -> bool:
249
+ """Check if the backend is healthy."""
250
+ return True
251
+
252
+ def close(self) -> None:
253
+ """Close the backend (no-op for memory backend)."""
254
+ pass
255
+
256
+ def clear(self) -> None:
257
+ """Clear all records."""
258
+ with self._lock:
259
+ self._records.clear()
260
+
261
+ @property
262
+ def record_count(self) -> int:
263
+ """Get the current number of records."""
264
+ with self._lock:
265
+ return len(self._records)