llm-cost-guard 0.1.1__py3-none-any.whl → 0.2.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.
@@ -15,8 +15,16 @@ from llm_cost_guard.exceptions import (
15
15
  TrackingUnavailableError,
16
16
  RateLimitExceededError,
17
17
  )
18
+ from llm_cost_guard.audit import (
19
+ AuditLogger,
20
+ AuditBackend,
21
+ AuditEvent,
22
+ AuditEventType,
23
+ LoggingAuditBackend,
24
+ FileAuditBackend,
25
+ )
18
26
 
19
- __version__ = "0.1.0"
27
+ __version__ = "0.2.0"
20
28
 
21
29
  __all__ = [
22
30
  # Core
@@ -29,6 +37,13 @@ __all__ = [
29
37
  "CostRecord",
30
38
  "CostReport",
31
39
  "HealthStatus",
40
+ # Audit
41
+ "AuditLogger",
42
+ "AuditBackend",
43
+ "AuditEvent",
44
+ "AuditEventType",
45
+ "LoggingAuditBackend",
46
+ "FileAuditBackend",
32
47
  # Exceptions
33
48
  "LLMCostGuardError",
34
49
  "BudgetExceededError",
@@ -0,0 +1,480 @@
1
+ """
2
+ Audit logging for LLM Cost Guard.
3
+
4
+ Provides compliance-ready audit trails for:
5
+ - Budget changes (created, modified, deleted)
6
+ - Budget exceeded events
7
+ - Configuration changes
8
+ - Rate limit events
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field, asdict
15
+ from datetime import datetime
16
+ from enum import Enum
17
+ from typing import Any, Dict, List, Optional, Callable
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class AuditEventType(str, Enum):
23
+ """Types of audit events."""
24
+
25
+ # Budget events
26
+ BUDGET_CREATED = "budget.created"
27
+ BUDGET_MODIFIED = "budget.modified"
28
+ BUDGET_DELETED = "budget.deleted"
29
+ BUDGET_WARNING = "budget.warning"
30
+ BUDGET_EXCEEDED = "budget.exceeded"
31
+ BUDGET_RESET = "budget.reset"
32
+
33
+ # Rate limit events
34
+ RATE_LIMIT_EXCEEDED = "rate_limit.exceeded"
35
+
36
+ # Configuration events
37
+ CONFIG_CHANGED = "config.changed"
38
+ BACKEND_CHANGED = "backend.changed"
39
+ PRICING_UPDATED = "pricing.updated"
40
+
41
+ # Tracking events
42
+ TRACKING_FAILURE = "tracking.failure"
43
+ FALLBACK_ACTIVATED = "fallback.activated"
44
+
45
+ # Cost events
46
+ COST_RECORDED = "cost.recorded"
47
+ COST_ANOMALY = "cost.anomaly"
48
+
49
+
50
+ @dataclass
51
+ class AuditEvent:
52
+ """Represents an audit event."""
53
+
54
+ event_type: AuditEventType
55
+ timestamp: datetime = field(default_factory=datetime.now)
56
+ actor: Optional[str] = None # Who initiated the action (user/system)
57
+ resource: Optional[str] = None # What was affected (budget name, etc.)
58
+ details: Dict[str, Any] = field(default_factory=dict)
59
+ metadata: Dict[str, Any] = field(default_factory=dict)
60
+
61
+ def to_dict(self) -> Dict[str, Any]:
62
+ """Convert to dictionary for serialization."""
63
+ return {
64
+ "event_type": self.event_type.value,
65
+ "timestamp": self.timestamp.isoformat(),
66
+ "actor": self.actor,
67
+ "resource": self.resource,
68
+ "details": self.details,
69
+ "metadata": self.metadata,
70
+ }
71
+
72
+ def to_json(self) -> str:
73
+ """Convert to JSON string."""
74
+ return json.dumps(self.to_dict())
75
+
76
+
77
+ class AuditBackend(ABC):
78
+ """Abstract base class for audit backends."""
79
+
80
+ @abstractmethod
81
+ def log(self, event: AuditEvent) -> None:
82
+ """Log an audit event."""
83
+ pass
84
+
85
+ @abstractmethod
86
+ def query(
87
+ self,
88
+ event_type: Optional[AuditEventType] = None,
89
+ start_date: Optional[datetime] = None,
90
+ end_date: Optional[datetime] = None,
91
+ resource: Optional[str] = None,
92
+ limit: int = 100,
93
+ ) -> List[AuditEvent]:
94
+ """Query audit events."""
95
+ pass
96
+
97
+
98
+ class LoggingAuditBackend(AuditBackend):
99
+ """
100
+ Audit backend that logs to Python logging.
101
+
102
+ Suitable for development and when audit logs should go to
103
+ existing log aggregation systems (CloudWatch, DataDog, etc.).
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ logger_name: str = "llm_cost_guard.audit",
109
+ log_level: int = logging.INFO,
110
+ ):
111
+ self._logger = logging.getLogger(logger_name)
112
+ self._log_level = log_level
113
+ self._events: List[AuditEvent] = [] # In-memory for query support
114
+ self._max_events = 10000 # Limit in-memory storage
115
+
116
+ def log(self, event: AuditEvent) -> None:
117
+ """Log an audit event."""
118
+ # Log to Python logger
119
+ self._logger.log(
120
+ self._log_level,
121
+ f"AUDIT: {event.event_type.value} | resource={event.resource} | {event.details}"
122
+ )
123
+
124
+ # Store in memory for queries
125
+ self._events.append(event)
126
+
127
+ # Trim if too many events
128
+ if len(self._events) > self._max_events:
129
+ self._events = self._events[-self._max_events:]
130
+
131
+ def query(
132
+ self,
133
+ event_type: Optional[AuditEventType] = None,
134
+ start_date: Optional[datetime] = None,
135
+ end_date: Optional[datetime] = None,
136
+ resource: Optional[str] = None,
137
+ limit: int = 100,
138
+ ) -> List[AuditEvent]:
139
+ """Query audit events."""
140
+ results = []
141
+
142
+ for event in reversed(self._events): # Most recent first
143
+ if event_type and event.event_type != event_type:
144
+ continue
145
+ if start_date and event.timestamp < start_date:
146
+ continue
147
+ if end_date and event.timestamp > end_date:
148
+ continue
149
+ if resource and event.resource != resource:
150
+ continue
151
+
152
+ results.append(event)
153
+
154
+ if len(results) >= limit:
155
+ break
156
+
157
+ return results
158
+
159
+
160
+ class FileAuditBackend(AuditBackend):
161
+ """
162
+ Audit backend that writes to a file (JSON Lines format).
163
+
164
+ Suitable for compliance requirements where audit logs need
165
+ to be stored in a specific location.
166
+ """
167
+
168
+ def __init__(self, file_path: str):
169
+ self._file_path = file_path
170
+
171
+ def log(self, event: AuditEvent) -> None:
172
+ """Log an audit event."""
173
+ with open(self._file_path, "a") as f:
174
+ f.write(event.to_json() + "\n")
175
+
176
+ def query(
177
+ self,
178
+ event_type: Optional[AuditEventType] = None,
179
+ start_date: Optional[datetime] = None,
180
+ end_date: Optional[datetime] = None,
181
+ resource: Optional[str] = None,
182
+ limit: int = 100,
183
+ ) -> List[AuditEvent]:
184
+ """Query audit events from file."""
185
+ results = []
186
+
187
+ try:
188
+ with open(self._file_path, "r") as f:
189
+ lines = f.readlines()
190
+ except FileNotFoundError:
191
+ return []
192
+
193
+ for line in reversed(lines): # Most recent first
194
+ if not line.strip():
195
+ continue
196
+
197
+ data = json.loads(line)
198
+ event = AuditEvent(
199
+ event_type=AuditEventType(data["event_type"]),
200
+ timestamp=datetime.fromisoformat(data["timestamp"]),
201
+ actor=data.get("actor"),
202
+ resource=data.get("resource"),
203
+ details=data.get("details", {}),
204
+ metadata=data.get("metadata", {}),
205
+ )
206
+
207
+ if event_type and event.event_type != event_type:
208
+ continue
209
+ if start_date and event.timestamp < start_date:
210
+ continue
211
+ if end_date and event.timestamp > end_date:
212
+ continue
213
+ if resource and event.resource != resource:
214
+ continue
215
+
216
+ results.append(event)
217
+
218
+ if len(results) >= limit:
219
+ break
220
+
221
+ return results
222
+
223
+
224
+ class CompositeAuditBackend(AuditBackend):
225
+ """
226
+ Audit backend that writes to multiple backends.
227
+
228
+ Useful for sending audit logs to both local storage and remote systems.
229
+ """
230
+
231
+ def __init__(self, backends: List[AuditBackend]):
232
+ self._backends = backends
233
+
234
+ def log(self, event: AuditEvent) -> None:
235
+ """Log an audit event to all backends."""
236
+ for backend in self._backends:
237
+ try:
238
+ backend.log(event)
239
+ except Exception as e:
240
+ logger.error(f"Audit backend failed: {e}")
241
+
242
+ def query(
243
+ self,
244
+ event_type: Optional[AuditEventType] = None,
245
+ start_date: Optional[datetime] = None,
246
+ end_date: Optional[datetime] = None,
247
+ resource: Optional[str] = None,
248
+ limit: int = 100,
249
+ ) -> List[AuditEvent]:
250
+ """Query from first backend that succeeds."""
251
+ for backend in self._backends:
252
+ try:
253
+ return backend.query(event_type, start_date, end_date, resource, limit)
254
+ except Exception as e:
255
+ logger.warning(f"Audit query failed on backend: {e}")
256
+ return []
257
+
258
+
259
+ class AuditLogger:
260
+ """
261
+ Main audit logger for LLM Cost Guard.
262
+
263
+ Usage:
264
+ audit = AuditLogger(backend=FileAuditBackend("audit.log"))
265
+
266
+ audit.log_budget_created(budget)
267
+ audit.log_budget_exceeded(budget, current_spending)
268
+ """
269
+
270
+ def __init__(
271
+ self,
272
+ backend: Optional[AuditBackend] = None,
273
+ enabled: bool = True,
274
+ actor: Optional[str] = None,
275
+ ):
276
+ self._backend = backend or LoggingAuditBackend()
277
+ self._enabled = enabled
278
+ self._default_actor = actor or "system"
279
+ self._event_callbacks: Dict[AuditEventType, List[Callable[[AuditEvent], None]]] = {}
280
+
281
+ def _log(self, event: AuditEvent) -> None:
282
+ """Internal logging method."""
283
+ if not self._enabled:
284
+ return
285
+
286
+ if event.actor is None:
287
+ event.actor = self._default_actor
288
+
289
+ self._backend.log(event)
290
+
291
+ # Trigger callbacks
292
+ callbacks = self._event_callbacks.get(event.event_type, [])
293
+ for callback in callbacks:
294
+ try:
295
+ callback(event)
296
+ except Exception as e:
297
+ logger.error(f"Audit callback failed: {e}")
298
+
299
+ def on_event(
300
+ self,
301
+ event_type: AuditEventType,
302
+ callback: Callable[[AuditEvent], None],
303
+ ) -> None:
304
+ """Register a callback for an event type."""
305
+ if event_type not in self._event_callbacks:
306
+ self._event_callbacks[event_type] = []
307
+ self._event_callbacks[event_type].append(callback)
308
+
309
+ # Budget events
310
+ def log_budget_created(
311
+ self,
312
+ budget_name: str,
313
+ limit: float,
314
+ period: str,
315
+ action: str,
316
+ actor: Optional[str] = None,
317
+ ) -> None:
318
+ """Log budget creation."""
319
+ self._log(AuditEvent(
320
+ event_type=AuditEventType.BUDGET_CREATED,
321
+ actor=actor,
322
+ resource=budget_name,
323
+ details={
324
+ "limit": limit,
325
+ "period": period,
326
+ "action": action,
327
+ },
328
+ ))
329
+
330
+ def log_budget_modified(
331
+ self,
332
+ budget_name: str,
333
+ old_values: Dict[str, Any],
334
+ new_values: Dict[str, Any],
335
+ actor: Optional[str] = None,
336
+ ) -> None:
337
+ """Log budget modification."""
338
+ self._log(AuditEvent(
339
+ event_type=AuditEventType.BUDGET_MODIFIED,
340
+ actor=actor,
341
+ resource=budget_name,
342
+ details={
343
+ "old_values": old_values,
344
+ "new_values": new_values,
345
+ },
346
+ ))
347
+
348
+ def log_budget_deleted(
349
+ self,
350
+ budget_name: str,
351
+ actor: Optional[str] = None,
352
+ ) -> None:
353
+ """Log budget deletion."""
354
+ self._log(AuditEvent(
355
+ event_type=AuditEventType.BUDGET_DELETED,
356
+ actor=actor,
357
+ resource=budget_name,
358
+ ))
359
+
360
+ def log_budget_warning(
361
+ self,
362
+ budget_name: str,
363
+ current_spending: float,
364
+ limit: float,
365
+ utilization: float,
366
+ ) -> None:
367
+ """Log budget warning."""
368
+ self._log(AuditEvent(
369
+ event_type=AuditEventType.BUDGET_WARNING,
370
+ resource=budget_name,
371
+ details={
372
+ "current_spending": current_spending,
373
+ "limit": limit,
374
+ "utilization_percent": utilization * 100,
375
+ },
376
+ ))
377
+
378
+ def log_budget_exceeded(
379
+ self,
380
+ budget_name: str,
381
+ current_spending: float,
382
+ limit: float,
383
+ action_taken: str,
384
+ ) -> None:
385
+ """Log budget exceeded event."""
386
+ self._log(AuditEvent(
387
+ event_type=AuditEventType.BUDGET_EXCEEDED,
388
+ resource=budget_name,
389
+ details={
390
+ "current_spending": current_spending,
391
+ "limit": limit,
392
+ "action_taken": action_taken,
393
+ },
394
+ ))
395
+
396
+ def log_budget_reset(
397
+ self,
398
+ budget_name: str,
399
+ reason: str = "period_end",
400
+ actor: Optional[str] = None,
401
+ ) -> None:
402
+ """Log budget reset."""
403
+ self._log(AuditEvent(
404
+ event_type=AuditEventType.BUDGET_RESET,
405
+ actor=actor,
406
+ resource=budget_name,
407
+ details={"reason": reason},
408
+ ))
409
+
410
+ # Rate limit events
411
+ def log_rate_limit_exceeded(
412
+ self,
413
+ limit_name: str,
414
+ current: int,
415
+ limit: int,
416
+ retry_after: float,
417
+ ) -> None:
418
+ """Log rate limit exceeded event."""
419
+ self._log(AuditEvent(
420
+ event_type=AuditEventType.RATE_LIMIT_EXCEEDED,
421
+ resource=limit_name,
422
+ details={
423
+ "current": current,
424
+ "limit": limit,
425
+ "retry_after_seconds": retry_after,
426
+ },
427
+ ))
428
+
429
+ # Tracking events
430
+ def log_tracking_failure(
431
+ self,
432
+ error: str,
433
+ backend: str,
434
+ action_taken: str,
435
+ ) -> None:
436
+ """Log tracking failure."""
437
+ self._log(AuditEvent(
438
+ event_type=AuditEventType.TRACKING_FAILURE,
439
+ resource=backend,
440
+ details={
441
+ "error": error,
442
+ "action_taken": action_taken,
443
+ },
444
+ ))
445
+
446
+ def log_fallback_activated(
447
+ self,
448
+ original_backend: str,
449
+ fallback_backend: str,
450
+ reason: str,
451
+ ) -> None:
452
+ """Log fallback activation."""
453
+ self._log(AuditEvent(
454
+ event_type=AuditEventType.FALLBACK_ACTIVATED,
455
+ details={
456
+ "original_backend": original_backend,
457
+ "fallback_backend": fallback_backend,
458
+ "reason": reason,
459
+ },
460
+ ))
461
+
462
+ # Query methods
463
+ def query(
464
+ self,
465
+ event_type: Optional[AuditEventType] = None,
466
+ start_date: Optional[datetime] = None,
467
+ end_date: Optional[datetime] = None,
468
+ resource: Optional[str] = None,
469
+ limit: int = 100,
470
+ ) -> List[AuditEvent]:
471
+ """Query audit events."""
472
+ return self._backend.query(event_type, start_date, end_date, resource, limit)
473
+
474
+ def get_budget_history(
475
+ self,
476
+ budget_name: str,
477
+ limit: int = 100,
478
+ ) -> List[AuditEvent]:
479
+ """Get audit history for a specific budget."""
480
+ return self._backend.query(resource=budget_name, limit=limit)
@@ -40,7 +40,7 @@ def get_backend(backend_url: str, **kwargs) -> Backend:
40
40
  return PostgresBackend(backend_url, **kwargs)
41
41
 
42
42
  if backend_url.startswith("redis://"):
43
- from llm_cost_guard.backends.redis import RedisBackend
43
+ from llm_cost_guard.backends.redis_backend import RedisBackend
44
44
 
45
45
  return RedisBackend(backend_url, **kwargs)
46
46