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
|
@@ -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)
|