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.
@@ -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,7 @@
1
+ """Storage backends for the API Service Handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import StorageBackend
6
+
7
+ __all__ = ["StorageBackend"]
@@ -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