api-service-handler 0.1.6__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.
- api_service_handler/__init__.py +77 -0
- api_service_handler/cli.py +341 -0
- api_service_handler/client.py +868 -0
- api_service_handler/config.py +177 -0
- api_service_handler/encryption.py +238 -0
- api_service_handler/enums.py +217 -0
- api_service_handler/exceptions.py +184 -0
- api_service_handler/models.py +301 -0
- api_service_handler/py.typed +0 -0
- api_service_handler/rate_limiter.py +187 -0
- api_service_handler/rotation.py +163 -0
- api_service_handler/storage/__init__.py +7 -0
- api_service_handler/storage/base.py +243 -0
- api_service_handler/storage/memory.py +229 -0
- api_service_handler/storage/mongodb.py +432 -0
- api_service_handler/storage/postgresql.py +429 -0
- api_service_handler/storage/sqlite.py +511 -0
- api_service_handler/usage_tracker.py +219 -0
- api_service_handler/utils.py +322 -0
- api_service_handler-0.1.6.dist-info/METADATA +282 -0
- api_service_handler-0.1.6.dist-info/RECORD +23 -0
- api_service_handler-0.1.6.dist-info/WHEEL +4 -0
- api_service_handler-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Key rotation strategies for selecting API keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .enums import Provider, RotationStrategy, KeyStatus
|
|
10
|
+
from .exceptions import NoAvailableKeyError
|
|
11
|
+
from .models import APIKey
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KeyRotator:
|
|
15
|
+
"""Manages key selection using configurable rotation strategies.
|
|
16
|
+
|
|
17
|
+
Supports round-robin, least-used, random, and weighted strategies.
|
|
18
|
+
All strategies automatically skip keys that are inactive, rate-limited,
|
|
19
|
+
expired, or at max concurrent usage.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, strategy: RotationStrategy | str = RotationStrategy.ROUND_ROBIN) -> None:
|
|
23
|
+
"""Initialize the rotator.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
strategy: The rotation strategy to use.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(strategy, str):
|
|
29
|
+
strategy = RotationStrategy(strategy)
|
|
30
|
+
self._strategy = strategy
|
|
31
|
+
# Round-robin index per provider
|
|
32
|
+
self._rr_counters: dict[str, int] = defaultdict(int)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def strategy(self) -> RotationStrategy:
|
|
36
|
+
"""The current rotation strategy."""
|
|
37
|
+
return self._strategy
|
|
38
|
+
|
|
39
|
+
@strategy.setter
|
|
40
|
+
def strategy(self, value: RotationStrategy | str) -> None:
|
|
41
|
+
"""Set the rotation strategy."""
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
value = RotationStrategy(value)
|
|
44
|
+
self._strategy = value
|
|
45
|
+
|
|
46
|
+
def select_key(
|
|
47
|
+
self,
|
|
48
|
+
keys: list[APIKey],
|
|
49
|
+
provider: Optional[Provider | str] = None,
|
|
50
|
+
) -> APIKey:
|
|
51
|
+
"""Select the next key using the configured strategy.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
keys: List of candidate keys to choose from.
|
|
55
|
+
provider: Optional provider to filter keys by.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The selected APIKey.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
NoAvailableKeyError: If no suitable key is available.
|
|
62
|
+
"""
|
|
63
|
+
# Filter to available keys
|
|
64
|
+
available = self._filter_available(keys, provider)
|
|
65
|
+
|
|
66
|
+
if not available:
|
|
67
|
+
provider_name = (
|
|
68
|
+
provider.value if isinstance(provider, Provider) else provider or "any"
|
|
69
|
+
)
|
|
70
|
+
raise NoAvailableKeyError(provider=provider_name)
|
|
71
|
+
|
|
72
|
+
# Sort by priority (lower = higher priority)
|
|
73
|
+
available.sort(key=lambda k: k.priority)
|
|
74
|
+
|
|
75
|
+
# Apply strategy
|
|
76
|
+
if self._strategy == RotationStrategy.ROUND_ROBIN:
|
|
77
|
+
return self._round_robin(available, provider)
|
|
78
|
+
elif self._strategy == RotationStrategy.LEAST_USED:
|
|
79
|
+
return self._least_used(available)
|
|
80
|
+
elif self._strategy == RotationStrategy.RANDOM:
|
|
81
|
+
return self._random(available)
|
|
82
|
+
elif self._strategy == RotationStrategy.WEIGHTED:
|
|
83
|
+
return self._weighted(available)
|
|
84
|
+
else:
|
|
85
|
+
return self._round_robin(available, provider)
|
|
86
|
+
|
|
87
|
+
def _filter_available(
|
|
88
|
+
self,
|
|
89
|
+
keys: list[APIKey],
|
|
90
|
+
provider: Optional[Provider | str] = None,
|
|
91
|
+
) -> list[APIKey]:
|
|
92
|
+
"""Filter keys to only those that are available for use.
|
|
93
|
+
|
|
94
|
+
A key is available if:
|
|
95
|
+
- Status is ACTIVE
|
|
96
|
+
- Not expired
|
|
97
|
+
- Not rate-limited (daily and monthly counts under limits)
|
|
98
|
+
- Concurrent usage under max_concurrent (if set)
|
|
99
|
+
"""
|
|
100
|
+
available = []
|
|
101
|
+
for key in keys:
|
|
102
|
+
# Filter by provider if specified
|
|
103
|
+
if provider is not None:
|
|
104
|
+
provider_str = (
|
|
105
|
+
provider.value if isinstance(provider, Provider) else provider
|
|
106
|
+
)
|
|
107
|
+
key_provider_str = (
|
|
108
|
+
key.provider.value if isinstance(key.provider, Provider) else key.provider
|
|
109
|
+
)
|
|
110
|
+
if key_provider_str != provider_str:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# Check status
|
|
114
|
+
key_status = key.status.value if isinstance(key.status, KeyStatus) else key.status
|
|
115
|
+
if key_status != KeyStatus.ACTIVE.value:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Check capacity using model property
|
|
119
|
+
if hasattr(key, "has_capacity") and not key.has_capacity:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
available.append(key)
|
|
123
|
+
|
|
124
|
+
return available
|
|
125
|
+
|
|
126
|
+
def _round_robin(
|
|
127
|
+
self,
|
|
128
|
+
keys: list[APIKey],
|
|
129
|
+
provider: Optional[Provider | str] = None,
|
|
130
|
+
) -> APIKey:
|
|
131
|
+
"""Select key using round-robin within the provider group."""
|
|
132
|
+
provider_key = str(provider) if provider else "_all"
|
|
133
|
+
idx = self._rr_counters[provider_key] % len(keys)
|
|
134
|
+
self._rr_counters[provider_key] = idx + 1
|
|
135
|
+
return keys[idx]
|
|
136
|
+
|
|
137
|
+
def _least_used(self, keys: list[APIKey]) -> APIKey:
|
|
138
|
+
"""Select the key with the lowest total usage count."""
|
|
139
|
+
return min(keys, key=lambda k: k.total_usage_count)
|
|
140
|
+
|
|
141
|
+
def _random(self, keys: list[APIKey]) -> APIKey:
|
|
142
|
+
"""Select a random key."""
|
|
143
|
+
return random.choice(keys)
|
|
144
|
+
|
|
145
|
+
def _weighted(self, keys: list[APIKey]) -> APIKey:
|
|
146
|
+
"""Select a key using weighted random selection.
|
|
147
|
+
|
|
148
|
+
Keys with higher weight values are more likely to be selected.
|
|
149
|
+
"""
|
|
150
|
+
weights = [k.weight for k in keys]
|
|
151
|
+
return random.choices(keys, weights=weights, k=1)[0]
|
|
152
|
+
|
|
153
|
+
def reset_counters(self, provider: Optional[str] = None) -> None:
|
|
154
|
+
"""Reset round-robin counters.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
provider: If set, only reset the counter for this provider.
|
|
158
|
+
If None, reset all counters.
|
|
159
|
+
"""
|
|
160
|
+
if provider:
|
|
161
|
+
self._rr_counters.pop(provider, None)
|
|
162
|
+
else:
|
|
163
|
+
self._rr_counters.clear()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Abstract base class for storage backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from datetime import date
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..enums import Provider, KeyStatus
|
|
10
|
+
from ..models import APIKey, KeyFilter, KeyUpdateRequest, BulkOperationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StorageBackend(ABC):
|
|
14
|
+
"""Abstract base class defining the storage interface.
|
|
15
|
+
|
|
16
|
+
All storage backends (Memory, SQLite, MongoDB, PostgreSQL) must implement
|
|
17
|
+
this interface. Methods are async to support async database drivers.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def initialize(self) -> None:
|
|
22
|
+
"""Initialize the storage backend (create tables/collections).
|
|
23
|
+
|
|
24
|
+
Must be called before any other operations.
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def close(self) -> None:
|
|
30
|
+
"""Close connections and clean up resources."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
# ── CRUD ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def add_key(self, key: APIKey) -> APIKey:
|
|
37
|
+
"""Insert a new API key record.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
key: The APIKey model to insert.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The inserted APIKey (with generated ID if not set).
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
DuplicateKeyError: If key_value + provider already exists.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def get_key(self, key_id: str) -> APIKey:
|
|
52
|
+
"""Retrieve a single key by its ID.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
key_id: The unique key identifier.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The matching APIKey.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
KeyNotFoundError: If no key exists with the given ID.
|
|
62
|
+
"""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def get_keys_by_provider(self, provider: Provider) -> list[APIKey]:
|
|
67
|
+
"""Retrieve all keys for a specific provider.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
provider: The API provider to filter by.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of matching APIKey records (may be empty).
|
|
74
|
+
"""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def get_all_keys(self, key_filter: Optional[KeyFilter] = None) -> list[APIKey]:
|
|
79
|
+
"""Retrieve all keys, optionally filtered.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
key_filter: Optional filter criteria.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of matching APIKey records.
|
|
86
|
+
"""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
async def update_key(self, key_id: str, updates: KeyUpdateRequest) -> APIKey:
|
|
91
|
+
"""Partially update an existing key.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
key_id: The key to update.
|
|
95
|
+
updates: Fields to update (only set fields are applied).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The updated APIKey.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
KeyNotFoundError: If no key exists with the given ID.
|
|
102
|
+
"""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
async def delete_key(self, key_id: str, soft: bool = True) -> bool:
|
|
107
|
+
"""Delete a key.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
key_id: The key to delete.
|
|
111
|
+
soft: If True, mark as REVOKED instead of hard deleting.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if the key was found and deleted/revoked.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
KeyNotFoundError: If no key exists with the given ID.
|
|
118
|
+
"""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
# ── Usage Tracking ─────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
async def increment_usage(
|
|
125
|
+
self,
|
|
126
|
+
key_id: str,
|
|
127
|
+
daily: int = 1,
|
|
128
|
+
monthly: int = 1,
|
|
129
|
+
total: int = 1,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Atomically increment usage counters for a key.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
key_id: The key whose counters to increment.
|
|
135
|
+
daily: Amount to add to daily_usage_count.
|
|
136
|
+
monthly: Amount to add to monthly_usage_count.
|
|
137
|
+
total: Amount to add to total_usage_count.
|
|
138
|
+
"""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
async def update_concurrent_usage(self, key_id: str, delta: int) -> int:
|
|
143
|
+
"""Atomically adjust the concurrent usage counter.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
key_id: The key to update.
|
|
147
|
+
delta: The change (+1 for acquire, -1 for release).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The new concurrent_usage value after the update.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
KeyNotFoundError: If no key exists with the given ID.
|
|
154
|
+
"""
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
@abstractmethod
|
|
158
|
+
async def reset_daily_counts(self, before_date: date) -> int:
|
|
159
|
+
"""Reset daily_usage_count for all keys with last_reset_daily before the given date.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
before_date: Reset keys whose last_reset_daily is before this date.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Number of keys reset.
|
|
166
|
+
"""
|
|
167
|
+
...
|
|
168
|
+
|
|
169
|
+
@abstractmethod
|
|
170
|
+
async def reset_monthly_counts(self, before_date: date) -> int:
|
|
171
|
+
"""Reset monthly_usage_count for all keys with last_reset_monthly before the given date.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
before_date: Reset keys whose last_reset_monthly is before this date.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Number of keys reset.
|
|
178
|
+
"""
|
|
179
|
+
...
|
|
180
|
+
|
|
181
|
+
@abstractmethod
|
|
182
|
+
async def update_last_used(self, key_id: str) -> None:
|
|
183
|
+
"""Update the last_used_at timestamp for a key.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
key_id: The key to update.
|
|
187
|
+
"""
|
|
188
|
+
...
|
|
189
|
+
|
|
190
|
+
# ── Bulk Operations ────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
@abstractmethod
|
|
193
|
+
async def bulk_add_keys(self, keys: list[APIKey]) -> BulkOperationResult:
|
|
194
|
+
"""Insert multiple keys at once.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
keys: List of APIKey models to insert.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
BulkOperationResult with success/failure counts.
|
|
201
|
+
"""
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
@abstractmethod
|
|
205
|
+
async def bulk_delete_keys(self, key_ids: list[str], soft: bool = True) -> BulkOperationResult:
|
|
206
|
+
"""Delete multiple keys at once.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
key_ids: List of key IDs to delete.
|
|
210
|
+
soft: If True, mark as REVOKED instead of hard deleting.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
BulkOperationResult with success/failure counts.
|
|
214
|
+
"""
|
|
215
|
+
...
|
|
216
|
+
|
|
217
|
+
# ── Health ─────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
@abstractmethod
|
|
220
|
+
async def health_check(self) -> bool:
|
|
221
|
+
"""Check if the storage backend is healthy and reachable.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if the backend is operational.
|
|
225
|
+
"""
|
|
226
|
+
...
|
|
227
|
+
|
|
228
|
+
@abstractmethod
|
|
229
|
+
async def count_keys(
|
|
230
|
+
self,
|
|
231
|
+
provider: Optional[Provider] = None,
|
|
232
|
+
status: Optional[KeyStatus] = None,
|
|
233
|
+
) -> int:
|
|
234
|
+
"""Count keys matching the given criteria.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
provider: Optional provider filter.
|
|
238
|
+
status: Optional status filter.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Number of matching keys.
|
|
242
|
+
"""
|
|
243
|
+
...
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""In-memory storage backend for testing and lightweight use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from datetime import date, datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..enums import Provider, KeyStatus
|
|
10
|
+
from ..exceptions import KeyNotFoundError, DuplicateKeyError
|
|
11
|
+
from ..models import APIKey, KeyFilter, KeyUpdateRequest, BulkOperationResult
|
|
12
|
+
from .base import StorageBackend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MemoryStorageBackend(StorageBackend):
|
|
16
|
+
"""In-memory dict-based storage backend.
|
|
17
|
+
|
|
18
|
+
Suitable for testing, prototyping, and lightweight single-process use.
|
|
19
|
+
Data is lost when the process exits.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._keys: dict[str, APIKey] = {}
|
|
24
|
+
self._initialized: bool = False
|
|
25
|
+
|
|
26
|
+
async def initialize(self) -> None:
|
|
27
|
+
"""Initialize the in-memory store."""
|
|
28
|
+
self._keys = {}
|
|
29
|
+
self._initialized = True
|
|
30
|
+
|
|
31
|
+
async def close(self) -> None:
|
|
32
|
+
"""Clear the in-memory store."""
|
|
33
|
+
self._keys.clear()
|
|
34
|
+
self._initialized = False
|
|
35
|
+
|
|
36
|
+
# ── CRUD ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
async def add_key(self, key: APIKey) -> APIKey:
|
|
39
|
+
"""Insert a new API key into memory."""
|
|
40
|
+
# Check for duplicates (same key_value + provider)
|
|
41
|
+
for existing in self._keys.values():
|
|
42
|
+
if (
|
|
43
|
+
existing.key_value == key.key_value
|
|
44
|
+
and existing.provider == key.provider
|
|
45
|
+
and existing.status != KeyStatus.REVOKED
|
|
46
|
+
):
|
|
47
|
+
raise DuplicateKeyError(provider=str(key.provider), key_value=key.key_value)
|
|
48
|
+
|
|
49
|
+
self._keys[key.id] = copy.deepcopy(key)
|
|
50
|
+
return copy.deepcopy(key)
|
|
51
|
+
|
|
52
|
+
async def get_key(self, key_id: str) -> APIKey:
|
|
53
|
+
"""Retrieve a key by ID."""
|
|
54
|
+
if key_id not in self._keys:
|
|
55
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
56
|
+
return copy.deepcopy(self._keys[key_id])
|
|
57
|
+
|
|
58
|
+
async def get_keys_by_provider(self, provider: Provider) -> list[APIKey]:
|
|
59
|
+
"""Get all keys for a provider."""
|
|
60
|
+
provider_str = provider.value if isinstance(provider, Provider) else provider
|
|
61
|
+
return [
|
|
62
|
+
copy.deepcopy(k)
|
|
63
|
+
for k in self._keys.values()
|
|
64
|
+
if (k.provider.value if isinstance(k.provider, Provider) else k.provider) == provider_str
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
async def get_all_keys(self, key_filter: Optional[KeyFilter] = None) -> list[APIKey]:
|
|
68
|
+
"""Get all keys, optionally filtered."""
|
|
69
|
+
keys = list(self._keys.values())
|
|
70
|
+
if key_filter:
|
|
71
|
+
keys = [k for k in keys if key_filter.matches(k)]
|
|
72
|
+
return [copy.deepcopy(k) for k in keys]
|
|
73
|
+
|
|
74
|
+
async def update_key(self, key_id: str, updates: KeyUpdateRequest) -> APIKey:
|
|
75
|
+
"""Update a key's fields."""
|
|
76
|
+
if key_id not in self._keys:
|
|
77
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
78
|
+
|
|
79
|
+
key = self._keys[key_id]
|
|
80
|
+
update_data = updates.model_dump(exclude_unset=True)
|
|
81
|
+
|
|
82
|
+
for field_name, value in update_data.items():
|
|
83
|
+
setattr(key, field_name, value)
|
|
84
|
+
|
|
85
|
+
key.updated_at = datetime.now(timezone.utc)
|
|
86
|
+
self._keys[key_id] = key
|
|
87
|
+
return copy.deepcopy(key)
|
|
88
|
+
|
|
89
|
+
async def delete_key(self, key_id: str, soft: bool = True) -> bool:
|
|
90
|
+
"""Delete or revoke a key."""
|
|
91
|
+
if key_id not in self._keys:
|
|
92
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
93
|
+
|
|
94
|
+
if soft:
|
|
95
|
+
self._keys[key_id].status = KeyStatus.REVOKED
|
|
96
|
+
self._keys[key_id].updated_at = datetime.now(timezone.utc)
|
|
97
|
+
else:
|
|
98
|
+
del self._keys[key_id]
|
|
99
|
+
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
# ── Usage Tracking ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
async def increment_usage(
|
|
105
|
+
self,
|
|
106
|
+
key_id: str,
|
|
107
|
+
daily: int = 1,
|
|
108
|
+
monthly: int = 1,
|
|
109
|
+
total: int = 1,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Increment usage counters."""
|
|
112
|
+
if key_id not in self._keys:
|
|
113
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
114
|
+
|
|
115
|
+
key = self._keys[key_id]
|
|
116
|
+
key.daily_usage_count += daily
|
|
117
|
+
key.monthly_usage_count += monthly
|
|
118
|
+
key.total_usage_count += total
|
|
119
|
+
key.last_used_at = datetime.now(timezone.utc)
|
|
120
|
+
key.updated_at = datetime.now(timezone.utc)
|
|
121
|
+
|
|
122
|
+
async def update_concurrent_usage(self, key_id: str, delta: int) -> int:
|
|
123
|
+
"""Adjust concurrent usage counter."""
|
|
124
|
+
if key_id not in self._keys:
|
|
125
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
126
|
+
|
|
127
|
+
key = self._keys[key_id]
|
|
128
|
+
key.concurrent_usage = max(0, key.concurrent_usage + delta)
|
|
129
|
+
key.updated_at = datetime.now(timezone.utc)
|
|
130
|
+
return key.concurrent_usage
|
|
131
|
+
|
|
132
|
+
async def reset_daily_counts(self, before_date: date) -> int:
|
|
133
|
+
"""Reset daily counts for keys not yet reset today."""
|
|
134
|
+
count = 0
|
|
135
|
+
for key in self._keys.values():
|
|
136
|
+
if key.last_reset_daily < before_date:
|
|
137
|
+
key.daily_usage_count = 0
|
|
138
|
+
key.last_reset_daily = before_date
|
|
139
|
+
if key.status == KeyStatus.RATE_LIMITED:
|
|
140
|
+
# Check if monthly limit is also hit
|
|
141
|
+
if key.monthly_limit is None or key.monthly_usage_count < key.monthly_limit:
|
|
142
|
+
key.status = KeyStatus.ACTIVE
|
|
143
|
+
key.updated_at = datetime.now(timezone.utc)
|
|
144
|
+
count += 1
|
|
145
|
+
return count
|
|
146
|
+
|
|
147
|
+
async def reset_monthly_counts(self, before_date: date) -> int:
|
|
148
|
+
"""Reset monthly counts for keys not yet reset this month."""
|
|
149
|
+
count = 0
|
|
150
|
+
for key in self._keys.values():
|
|
151
|
+
if (
|
|
152
|
+
key.last_reset_monthly.month != before_date.month
|
|
153
|
+
or key.last_reset_monthly.year != before_date.year
|
|
154
|
+
):
|
|
155
|
+
key.monthly_usage_count = 0
|
|
156
|
+
key.last_reset_monthly = before_date
|
|
157
|
+
if key.status == KeyStatus.RATE_LIMITED:
|
|
158
|
+
key.status = KeyStatus.ACTIVE
|
|
159
|
+
key.updated_at = datetime.now(timezone.utc)
|
|
160
|
+
count += 1
|
|
161
|
+
return count
|
|
162
|
+
|
|
163
|
+
async def update_last_used(self, key_id: str) -> None:
|
|
164
|
+
"""Update last_used_at timestamp."""
|
|
165
|
+
if key_id not in self._keys:
|
|
166
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
167
|
+
self._keys[key_id].last_used_at = datetime.now(timezone.utc)
|
|
168
|
+
|
|
169
|
+
# ── Bulk Operations ────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
async def bulk_add_keys(self, keys: list[APIKey]) -> BulkOperationResult:
|
|
172
|
+
"""Insert multiple keys."""
|
|
173
|
+
result = BulkOperationResult(total=len(keys))
|
|
174
|
+
|
|
175
|
+
for key in keys:
|
|
176
|
+
try:
|
|
177
|
+
added = await self.add_key(key)
|
|
178
|
+
result.successful += 1
|
|
179
|
+
result.created_ids.append(added.id)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
result.failed += 1
|
|
182
|
+
result.errors.append(f"Key {key.alias or key.id}: {e}")
|
|
183
|
+
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
async def bulk_delete_keys(self, key_ids: list[str], soft: bool = True) -> BulkOperationResult:
|
|
187
|
+
"""Delete multiple keys."""
|
|
188
|
+
result = BulkOperationResult(total=len(key_ids))
|
|
189
|
+
|
|
190
|
+
for key_id in key_ids:
|
|
191
|
+
try:
|
|
192
|
+
await self.delete_key(key_id, soft=soft)
|
|
193
|
+
result.successful += 1
|
|
194
|
+
except Exception as e:
|
|
195
|
+
result.failed += 1
|
|
196
|
+
result.errors.append(f"Key {key_id}: {e}")
|
|
197
|
+
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
# ── Health ─────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async def health_check(self) -> bool:
|
|
203
|
+
"""Memory backend is always healthy if initialized."""
|
|
204
|
+
return self._initialized
|
|
205
|
+
|
|
206
|
+
async def count_keys(
|
|
207
|
+
self,
|
|
208
|
+
provider: Optional[Provider] = None,
|
|
209
|
+
status: Optional[KeyStatus] = None,
|
|
210
|
+
) -> int:
|
|
211
|
+
"""Count keys matching criteria."""
|
|
212
|
+
count = 0
|
|
213
|
+
for key in self._keys.values():
|
|
214
|
+
if provider is not None:
|
|
215
|
+
provider_str = provider.value if isinstance(provider, Provider) else provider
|
|
216
|
+
key_provider_str = (
|
|
217
|
+
key.provider.value if isinstance(key.provider, Provider) else key.provider
|
|
218
|
+
)
|
|
219
|
+
if key_provider_str != provider_str:
|
|
220
|
+
continue
|
|
221
|
+
if status is not None:
|
|
222
|
+
status_str = status.value if isinstance(status, KeyStatus) else status
|
|
223
|
+
key_status_str = (
|
|
224
|
+
key.status.value if isinstance(key.status, KeyStatus) else key.status
|
|
225
|
+
)
|
|
226
|
+
if key_status_str != status_str:
|
|
227
|
+
continue
|
|
228
|
+
count += 1
|
|
229
|
+
return count
|