llm-cost-guard 0.1.2__tar.gz → 0.2.0__tar.gz
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-0.1.2 → llm_cost_guard-0.2.0}/PKG-INFO +76 -1
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/README.md +75 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/__init__.py +16 -1
- llm_cost_guard-0.2.0/llm_cost_guard/audit.py +480 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/backends/__init__.py +1 -1
- llm_cost_guard-0.2.0/llm_cost_guard/backends/redis_backend.py +557 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/tracker.py +114 -3
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/pyproject.toml +1 -1
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/.github/workflows/ci.yml +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/.github/workflows/publish.yml +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/.gitignore +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/LICENSE +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/examples/bedrock_example.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/examples/budget_alerts.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/examples/distributed_tracking.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/examples/langchain_rag.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/examples/quickstart.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/examples/streaming_example.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/backends/base.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/backends/memory.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/backends/sqlite.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/budget.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/cli.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/clients/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/clients/anthropic.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/clients/openai.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/exceptions.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/integrations/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/integrations/cache.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/integrations/langchain.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/models.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/pricing/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/pricing/anthropic.yaml +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/pricing/bedrock.yaml +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/pricing/loader.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/pricing/openai.yaml +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/pricing/vertex.yaml +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/providers/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/providers/anthropic.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/providers/base.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/providers/bedrock.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/providers/openai.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/rate_limit.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/span.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/tokenizers/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/llm_cost_guard/tokenizers/base.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/conftest.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/integration/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/integration/test_e2e.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/__init__.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/test_backends.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/test_budget.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/test_pricing.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/test_providers.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/test_rate_limit.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/test_span.py +0 -0
- {llm_cost_guard-0.1.2 → llm_cost_guard-0.2.0}/tests/unit/test_tracker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llm-cost-guard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Real-time cost tracking, budget enforcement, and usage analytics for LLM applications
|
|
5
5
|
Project-URL: Homepage, https://github.com/prashantdudami/llm-cost-guard
|
|
6
6
|
Project-URL: Documentation, https://github.com/prashantdudami/llm-cost-guard#readme
|
|
@@ -334,6 +334,57 @@ tracker = CostTracker(
|
|
|
334
334
|
)
|
|
335
335
|
```
|
|
336
336
|
|
|
337
|
+
## Audit Logging (v0.2.0+)
|
|
338
|
+
|
|
339
|
+
Enterprise-ready audit trails for compliance:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
from llm_cost_guard import CostTracker, FileAuditBackend
|
|
343
|
+
|
|
344
|
+
# Enable audit logging
|
|
345
|
+
tracker = CostTracker(
|
|
346
|
+
audit_enabled=True,
|
|
347
|
+
audit_backend=FileAuditBackend("audit.log"),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Query audit history
|
|
351
|
+
events = tracker.audit.query(
|
|
352
|
+
event_type=AuditEventType.BUDGET_EXCEEDED,
|
|
353
|
+
start_date="2024-01-01",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Get budget-specific history
|
|
357
|
+
history = tracker.audit.get_budget_history("daily")
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Audit events include:
|
|
361
|
+
- Budget created/modified/deleted
|
|
362
|
+
- Budget warnings and exceeded events
|
|
363
|
+
- Rate limit exceeded events
|
|
364
|
+
- Tracking failures and fallback activations
|
|
365
|
+
|
|
366
|
+
## Observability Metrics (v0.2.0+)
|
|
367
|
+
|
|
368
|
+
Track health and degradation:
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
# Get tracker metrics
|
|
372
|
+
metrics = tracker.get_metrics()
|
|
373
|
+
print(metrics)
|
|
374
|
+
# {
|
|
375
|
+
# "backend_failures": 0,
|
|
376
|
+
# "fallback_activations": 0,
|
|
377
|
+
# "budget_exceeded_count": 3,
|
|
378
|
+
# "tracking_errors": 0,
|
|
379
|
+
# "using_fallback": False,
|
|
380
|
+
# }
|
|
381
|
+
|
|
382
|
+
# Health check
|
|
383
|
+
health = tracker.health_check()
|
|
384
|
+
print(health.healthy) # True/False
|
|
385
|
+
print(health.errors) # List of issues
|
|
386
|
+
```
|
|
387
|
+
|
|
337
388
|
## Custom Pricing
|
|
338
389
|
|
|
339
390
|
For negotiated enterprise rates:
|
|
@@ -349,6 +400,30 @@ tracker = CostTracker(
|
|
|
349
400
|
)
|
|
350
401
|
```
|
|
351
402
|
|
|
403
|
+
## Current Limitations
|
|
404
|
+
|
|
405
|
+
Being transparent about what's not yet production-ready:
|
|
406
|
+
|
|
407
|
+
| Feature | Status | Notes |
|
|
408
|
+
|---------|--------|-------|
|
|
409
|
+
| Distributed budgets (Redis) | ✅ v0.2.0 | Atomic operations with Lua scripts |
|
|
410
|
+
| Audit logging | ✅ v0.2.0 | File and logging backends |
|
|
411
|
+
| Graceful degradation metrics | ✅ v0.2.0 | Track failures and fallbacks |
|
|
412
|
+
| PostgreSQL backend | 🚧 Planned | Use SQLite or Redis for now |
|
|
413
|
+
| DynamoDB backend | 🚧 Planned | Use SQLite or Redis for now |
|
|
414
|
+
| Encryption at rest | 🚧 Planned | Use encrypted volumes as workaround |
|
|
415
|
+
| Multi-tenancy optimization | 🚧 Planned | Use tag-scoped budgets for now |
|
|
416
|
+
| Streaming cost estimation | ⚠️ Limited | Actual cost tracked on completion |
|
|
417
|
+
| Fine-tuning cost tracking | ❌ Not supported | |
|
|
418
|
+
|
|
419
|
+
### Recommended for Production
|
|
420
|
+
|
|
421
|
+
| Deployment Size | Backend | Notes |
|
|
422
|
+
|-----------------|---------|-------|
|
|
423
|
+
| Single instance | SQLite | Simple, no setup |
|
|
424
|
+
| Multiple instances | Redis | Distributed budget enforcement |
|
|
425
|
+
| High-volume (>1k req/s) | Redis | With sampling (coming soon) |
|
|
426
|
+
|
|
352
427
|
## Contributing
|
|
353
428
|
|
|
354
429
|
Contributions are welcome! Please read our contributing guidelines and submit pull requests.
|
|
@@ -271,6 +271,57 @@ tracker = CostTracker(
|
|
|
271
271
|
)
|
|
272
272
|
```
|
|
273
273
|
|
|
274
|
+
## Audit Logging (v0.2.0+)
|
|
275
|
+
|
|
276
|
+
Enterprise-ready audit trails for compliance:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from llm_cost_guard import CostTracker, FileAuditBackend
|
|
280
|
+
|
|
281
|
+
# Enable audit logging
|
|
282
|
+
tracker = CostTracker(
|
|
283
|
+
audit_enabled=True,
|
|
284
|
+
audit_backend=FileAuditBackend("audit.log"),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Query audit history
|
|
288
|
+
events = tracker.audit.query(
|
|
289
|
+
event_type=AuditEventType.BUDGET_EXCEEDED,
|
|
290
|
+
start_date="2024-01-01",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Get budget-specific history
|
|
294
|
+
history = tracker.audit.get_budget_history("daily")
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Audit events include:
|
|
298
|
+
- Budget created/modified/deleted
|
|
299
|
+
- Budget warnings and exceeded events
|
|
300
|
+
- Rate limit exceeded events
|
|
301
|
+
- Tracking failures and fallback activations
|
|
302
|
+
|
|
303
|
+
## Observability Metrics (v0.2.0+)
|
|
304
|
+
|
|
305
|
+
Track health and degradation:
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
# Get tracker metrics
|
|
309
|
+
metrics = tracker.get_metrics()
|
|
310
|
+
print(metrics)
|
|
311
|
+
# {
|
|
312
|
+
# "backend_failures": 0,
|
|
313
|
+
# "fallback_activations": 0,
|
|
314
|
+
# "budget_exceeded_count": 3,
|
|
315
|
+
# "tracking_errors": 0,
|
|
316
|
+
# "using_fallback": False,
|
|
317
|
+
# }
|
|
318
|
+
|
|
319
|
+
# Health check
|
|
320
|
+
health = tracker.health_check()
|
|
321
|
+
print(health.healthy) # True/False
|
|
322
|
+
print(health.errors) # List of issues
|
|
323
|
+
```
|
|
324
|
+
|
|
274
325
|
## Custom Pricing
|
|
275
326
|
|
|
276
327
|
For negotiated enterprise rates:
|
|
@@ -286,6 +337,30 @@ tracker = CostTracker(
|
|
|
286
337
|
)
|
|
287
338
|
```
|
|
288
339
|
|
|
340
|
+
## Current Limitations
|
|
341
|
+
|
|
342
|
+
Being transparent about what's not yet production-ready:
|
|
343
|
+
|
|
344
|
+
| Feature | Status | Notes |
|
|
345
|
+
|---------|--------|-------|
|
|
346
|
+
| Distributed budgets (Redis) | ✅ v0.2.0 | Atomic operations with Lua scripts |
|
|
347
|
+
| Audit logging | ✅ v0.2.0 | File and logging backends |
|
|
348
|
+
| Graceful degradation metrics | ✅ v0.2.0 | Track failures and fallbacks |
|
|
349
|
+
| PostgreSQL backend | 🚧 Planned | Use SQLite or Redis for now |
|
|
350
|
+
| DynamoDB backend | 🚧 Planned | Use SQLite or Redis for now |
|
|
351
|
+
| Encryption at rest | 🚧 Planned | Use encrypted volumes as workaround |
|
|
352
|
+
| Multi-tenancy optimization | 🚧 Planned | Use tag-scoped budgets for now |
|
|
353
|
+
| Streaming cost estimation | ⚠️ Limited | Actual cost tracked on completion |
|
|
354
|
+
| Fine-tuning cost tracking | ❌ Not supported | |
|
|
355
|
+
|
|
356
|
+
### Recommended for Production
|
|
357
|
+
|
|
358
|
+
| Deployment Size | Backend | Notes |
|
|
359
|
+
|-----------------|---------|-------|
|
|
360
|
+
| Single instance | SQLite | Simple, no setup |
|
|
361
|
+
| Multiple instances | Redis | Distributed budget enforcement |
|
|
362
|
+
| High-volume (>1k req/s) | Redis | With sampling (coming soon) |
|
|
363
|
+
|
|
289
364
|
## Contributing
|
|
290
365
|
|
|
291
366
|
Contributions are welcome! Please read our contributing guidelines and submit pull requests.
|
|
@@ -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.
|
|
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.
|
|
43
|
+
from llm_cost_guard.backends.redis_backend import RedisBackend
|
|
44
44
|
|
|
45
45
|
return RedisBackend(backend_url, **kwargs)
|
|
46
46
|
|