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,868 @@
|
|
|
1
|
+
"""Main client for the API Service Handler library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, AsyncIterator, Optional
|
|
9
|
+
|
|
10
|
+
from .config import ASHConfig
|
|
11
|
+
from .encryption import decrypt_api_key, encrypt_api_key
|
|
12
|
+
from .enums import (
|
|
13
|
+
Environment,
|
|
14
|
+
KeyStatus,
|
|
15
|
+
Provider,
|
|
16
|
+
RotationStrategy,
|
|
17
|
+
StorageBackend as StorageBackendEnum,
|
|
18
|
+
)
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
NoAvailableKeyError,
|
|
21
|
+
StorageNotInitializedError,
|
|
22
|
+
)
|
|
23
|
+
from .models import (
|
|
24
|
+
APIKey,
|
|
25
|
+
BulkOperationResult,
|
|
26
|
+
KeyCreateRequest,
|
|
27
|
+
KeyFilter,
|
|
28
|
+
KeyUpdateRequest,
|
|
29
|
+
UsageStats,
|
|
30
|
+
)
|
|
31
|
+
from .rate_limiter import RateLimiter
|
|
32
|
+
from .rotation import KeyRotator
|
|
33
|
+
from .storage.base import StorageBackend
|
|
34
|
+
from .usage_tracker import UsageTracker
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _create_storage(config: ASHConfig) -> StorageBackend:
|
|
38
|
+
"""Create a storage backend from config.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
config: The ASH configuration.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
An uninitialized storage backend instance.
|
|
45
|
+
"""
|
|
46
|
+
backend = config.storage_backend.lower()
|
|
47
|
+
|
|
48
|
+
if backend == "memory":
|
|
49
|
+
from .storage.memory import MemoryStorageBackend
|
|
50
|
+
return MemoryStorageBackend()
|
|
51
|
+
|
|
52
|
+
elif backend == "sqlite":
|
|
53
|
+
from .storage.sqlite import SQLiteStorageBackend
|
|
54
|
+
return SQLiteStorageBackend(config.connection_string)
|
|
55
|
+
|
|
56
|
+
elif backend == "mongodb":
|
|
57
|
+
from .storage.mongodb import MongoDBStorageBackend
|
|
58
|
+
return MongoDBStorageBackend(config.connection_string, metadata_indexes=config.metadata_indexes)
|
|
59
|
+
|
|
60
|
+
elif backend == "postgresql":
|
|
61
|
+
from .storage.postgresql import PostgreSQLStorageBackend
|
|
62
|
+
return PostgreSQLStorageBackend(config.connection_string)
|
|
63
|
+
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Unknown storage backend: '{backend}'. "
|
|
67
|
+
f"Supported: memory, sqlite, mongodb, postgresql"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class APIServiceHandler:
|
|
72
|
+
"""Main entry point for the API Service Handler library.
|
|
73
|
+
|
|
74
|
+
Provides a unified async API for managing API keys across providers
|
|
75
|
+
with support for:
|
|
76
|
+
- CRUD operations on API keys
|
|
77
|
+
- Multiple storage backends (memory, SQLite, MongoDB, PostgreSQL)
|
|
78
|
+
- AES-256-GCM encryption of key values at rest
|
|
79
|
+
- Round-robin, least-used, random, and weighted key rotation
|
|
80
|
+
- Daily and monthly rate limiting with auto-reset
|
|
81
|
+
- Concurrent usage tracking with acquire/release
|
|
82
|
+
- Metadata and tag-based filtering
|
|
83
|
+
- Bulk operations
|
|
84
|
+
|
|
85
|
+
Example::
|
|
86
|
+
|
|
87
|
+
handler = APIServiceHandler(
|
|
88
|
+
storage_backend="sqlite",
|
|
89
|
+
connection_string="sqlite:///keys.db",
|
|
90
|
+
shared_secret="my-secret",
|
|
91
|
+
)
|
|
92
|
+
await handler.initialize()
|
|
93
|
+
|
|
94
|
+
key = await handler.add_key(
|
|
95
|
+
provider=Provider.OPENAI,
|
|
96
|
+
key_value="sk-abc123...",
|
|
97
|
+
alias="prod-openai-1",
|
|
98
|
+
daily_limit=1000,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async with handler.use_key(Provider.OPENAI) as active_key:
|
|
102
|
+
response = await call_api(active_key.key_value)
|
|
103
|
+
|
|
104
|
+
await handler.close()
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
storage_backend: str = "memory",
|
|
110
|
+
connection_string: str = "",
|
|
111
|
+
shared_secret: str = "",
|
|
112
|
+
rotation_strategy: str = "round_robin",
|
|
113
|
+
encrypt_keys: bool = True,
|
|
114
|
+
auto_reset_counters: bool = True,
|
|
115
|
+
soft_delete: bool = True,
|
|
116
|
+
default_daily_limit: Optional[int] = None,
|
|
117
|
+
default_monthly_limit: Optional[int] = None,
|
|
118
|
+
default_max_concurrent: Optional[int] = None,
|
|
119
|
+
metadata_indexes: Optional[list[str]] = None,
|
|
120
|
+
config: Optional[ASHConfig] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Initialize the API Service Handler.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
storage_backend: Storage backend name ('memory', 'sqlite', 'mongodb', 'postgresql').
|
|
126
|
+
connection_string: Database connection string.
|
|
127
|
+
shared_secret: Secret for AES-GCM encryption. Falls back to ASH_SHARED_SECRET env var.
|
|
128
|
+
rotation_strategy: Key selection strategy ('round_robin', 'least_used', 'random', 'weighted').
|
|
129
|
+
encrypt_keys: Whether to encrypt key values at rest.
|
|
130
|
+
auto_reset_counters: Auto-reset daily/monthly counters when stale.
|
|
131
|
+
soft_delete: Use soft delete (mark as REVOKED) instead of hard delete.
|
|
132
|
+
default_daily_limit: Default daily limit for new keys.
|
|
133
|
+
default_monthly_limit: Default monthly limit for new keys.
|
|
134
|
+
default_max_concurrent: Default max concurrent for new keys.
|
|
135
|
+
metadata_indexes: List of metadata keys to compound index with provider (e.g. ['client_id']).
|
|
136
|
+
config: Optional pre-built ASHConfig (overrides individual params).
|
|
137
|
+
"""
|
|
138
|
+
if config:
|
|
139
|
+
self._config = config
|
|
140
|
+
else:
|
|
141
|
+
self._config = ASHConfig(
|
|
142
|
+
storage_backend=storage_backend,
|
|
143
|
+
connection_string=connection_string,
|
|
144
|
+
shared_secret=shared_secret,
|
|
145
|
+
encrypt_keys=encrypt_keys,
|
|
146
|
+
rotation_strategy=rotation_strategy,
|
|
147
|
+
auto_reset_counters=auto_reset_counters,
|
|
148
|
+
soft_delete=soft_delete,
|
|
149
|
+
default_daily_limit=default_daily_limit,
|
|
150
|
+
default_monthly_limit=default_monthly_limit,
|
|
151
|
+
default_max_concurrent=default_max_concurrent,
|
|
152
|
+
metadata_indexes=metadata_indexes or [],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self._storage: StorageBackend = _create_storage(self._config)
|
|
156
|
+
self._rotator = KeyRotator(self._config.rotation_strategy)
|
|
157
|
+
self._rate_limiter: Optional[RateLimiter] = None
|
|
158
|
+
self._usage_tracker: Optional[UsageTracker] = None
|
|
159
|
+
self._initialized = False
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def config(self) -> ASHConfig:
|
|
163
|
+
"""The current configuration."""
|
|
164
|
+
return self._config
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def storage(self) -> StorageBackend:
|
|
168
|
+
"""The underlying storage backend."""
|
|
169
|
+
return self._storage
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def is_initialized(self) -> bool:
|
|
173
|
+
"""Whether the handler has been initialized."""
|
|
174
|
+
return self._initialized
|
|
175
|
+
|
|
176
|
+
# ── Lifecycle ──────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
async def initialize(self) -> None:
|
|
179
|
+
"""Initialize the storage backend and internal components.
|
|
180
|
+
|
|
181
|
+
Must be called before any other operations.
|
|
182
|
+
"""
|
|
183
|
+
await self._storage.initialize()
|
|
184
|
+
self._rate_limiter = RateLimiter(
|
|
185
|
+
self._storage, auto_reset=self._config.auto_reset_counters
|
|
186
|
+
)
|
|
187
|
+
self._usage_tracker = UsageTracker(self._storage)
|
|
188
|
+
self._initialized = True
|
|
189
|
+
|
|
190
|
+
async def close(self) -> None:
|
|
191
|
+
"""Close the storage backend and clean up resources."""
|
|
192
|
+
if self._storage:
|
|
193
|
+
await self._storage.close()
|
|
194
|
+
self._initialized = False
|
|
195
|
+
|
|
196
|
+
def _ensure_initialized(self) -> None:
|
|
197
|
+
"""Raise if not initialized."""
|
|
198
|
+
if not self._initialized:
|
|
199
|
+
raise StorageNotInitializedError()
|
|
200
|
+
|
|
201
|
+
# ── Key CRUD ──────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async def add_key(
|
|
204
|
+
self,
|
|
205
|
+
provider: Provider | str,
|
|
206
|
+
key_value: str,
|
|
207
|
+
alias: Optional[str] = None,
|
|
208
|
+
daily_limit: Optional[int] = None,
|
|
209
|
+
monthly_limit: Optional[int] = None,
|
|
210
|
+
max_concurrent: Optional[int] = None,
|
|
211
|
+
environment: Environment | str = Environment.PRODUCTION,
|
|
212
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
213
|
+
tags: Optional[list[str]] = None,
|
|
214
|
+
expires_at: Optional[datetime] = None,
|
|
215
|
+
weight: int = 1,
|
|
216
|
+
priority: int = 0,
|
|
217
|
+
) -> APIKey:
|
|
218
|
+
"""Add a new API key.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
provider: The API provider (e.g., Provider.OPENAI or "openai").
|
|
222
|
+
key_value: The actual API key string.
|
|
223
|
+
alias: Human-friendly name for the key.
|
|
224
|
+
daily_limit: Max requests per day (None = unlimited).
|
|
225
|
+
monthly_limit: Max requests per month (None = unlimited).
|
|
226
|
+
max_concurrent: Max simultaneous uses (None = unlimited).
|
|
227
|
+
environment: Deployment environment.
|
|
228
|
+
metadata: Arbitrary key-value metadata.
|
|
229
|
+
tags: Free-form tags.
|
|
230
|
+
expires_at: Expiration timestamp.
|
|
231
|
+
weight: Weight for weighted rotation (default: 1).
|
|
232
|
+
priority: Priority rank, lower = higher priority (default: 0).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The created APIKey with generated ID.
|
|
236
|
+
"""
|
|
237
|
+
self._ensure_initialized()
|
|
238
|
+
|
|
239
|
+
# Resolve provider
|
|
240
|
+
if isinstance(provider, str):
|
|
241
|
+
provider = Provider.from_string(provider)
|
|
242
|
+
|
|
243
|
+
# Apply defaults from config
|
|
244
|
+
if daily_limit is None and self._config.default_daily_limit is not None:
|
|
245
|
+
daily_limit = self._config.default_daily_limit
|
|
246
|
+
if monthly_limit is None and self._config.default_monthly_limit is not None:
|
|
247
|
+
monthly_limit = self._config.default_monthly_limit
|
|
248
|
+
if max_concurrent is None and self._config.default_max_concurrent is not None:
|
|
249
|
+
max_concurrent = self._config.default_max_concurrent
|
|
250
|
+
|
|
251
|
+
# Encrypt key value if configured
|
|
252
|
+
stored_key_value = key_value
|
|
253
|
+
if self._config.encrypt_keys and self._config.shared_secret:
|
|
254
|
+
stored_key_value = encrypt_api_key(key_value, self._config.shared_secret)
|
|
255
|
+
|
|
256
|
+
key = APIKey(
|
|
257
|
+
provider=provider,
|
|
258
|
+
key_value=stored_key_value,
|
|
259
|
+
alias=alias,
|
|
260
|
+
daily_limit=daily_limit,
|
|
261
|
+
monthly_limit=monthly_limit,
|
|
262
|
+
max_concurrent=max_concurrent,
|
|
263
|
+
environment=environment,
|
|
264
|
+
metadata=metadata or {},
|
|
265
|
+
tags=tags or [],
|
|
266
|
+
expires_at=expires_at,
|
|
267
|
+
weight=weight,
|
|
268
|
+
priority=priority,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return await self._storage.add_key(key)
|
|
272
|
+
|
|
273
|
+
async def get_key(self, key_id: str, decrypt: bool = True) -> APIKey:
|
|
274
|
+
"""Get a key by its ID.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
key_id: The unique key identifier.
|
|
278
|
+
decrypt: If True, decrypt the key_value before returning.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The matching APIKey.
|
|
282
|
+
"""
|
|
283
|
+
self._ensure_initialized()
|
|
284
|
+
key = await self._storage.get_key(key_id)
|
|
285
|
+
if decrypt and self._config.encrypt_keys and self._config.shared_secret:
|
|
286
|
+
key.key_value = decrypt_api_key(key.key_value, self._config.shared_secret)
|
|
287
|
+
return key
|
|
288
|
+
|
|
289
|
+
async def get_keys_by_provider(
|
|
290
|
+
self,
|
|
291
|
+
provider: Provider | str,
|
|
292
|
+
decrypt: bool = False,
|
|
293
|
+
) -> list[APIKey]:
|
|
294
|
+
"""Get all keys for a specific provider.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
provider: The API provider to filter by.
|
|
298
|
+
decrypt: If True, decrypt key values.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
List of matching APIKey records.
|
|
302
|
+
"""
|
|
303
|
+
self._ensure_initialized()
|
|
304
|
+
if isinstance(provider, str):
|
|
305
|
+
provider = Provider.from_string(provider)
|
|
306
|
+
keys = await self._storage.get_keys_by_provider(provider)
|
|
307
|
+
if decrypt and self._config.encrypt_keys and self._config.shared_secret:
|
|
308
|
+
for key in keys:
|
|
309
|
+
key.key_value = decrypt_api_key(key.key_value, self._config.shared_secret)
|
|
310
|
+
return keys
|
|
311
|
+
|
|
312
|
+
async def get_all_keys(
|
|
313
|
+
self,
|
|
314
|
+
provider: Optional[Provider | str] = None,
|
|
315
|
+
status: Optional[KeyStatus | str] = None,
|
|
316
|
+
environment: Optional[Environment | str] = None,
|
|
317
|
+
tags: Optional[list[str]] = None,
|
|
318
|
+
metadata_filter: Optional[dict[str, Any]] = None,
|
|
319
|
+
has_capacity: Optional[bool] = None,
|
|
320
|
+
alias_contains: Optional[str] = None,
|
|
321
|
+
decrypt: bool = False,
|
|
322
|
+
) -> list[APIKey]:
|
|
323
|
+
"""Get all keys with optional filtering.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
provider: Filter by provider.
|
|
327
|
+
status: Filter by status.
|
|
328
|
+
environment: Filter by environment.
|
|
329
|
+
tags: Filter by tags (any match).
|
|
330
|
+
metadata_filter: Filter by metadata (all must match).
|
|
331
|
+
has_capacity: If True, only keys with remaining capacity.
|
|
332
|
+
alias_contains: Substring search on alias.
|
|
333
|
+
decrypt: If True, decrypt key values.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of matching APIKey records.
|
|
337
|
+
"""
|
|
338
|
+
self._ensure_initialized()
|
|
339
|
+
|
|
340
|
+
# Build filter
|
|
341
|
+
key_filter = None
|
|
342
|
+
if any(
|
|
343
|
+
v is not None
|
|
344
|
+
for v in [provider, status, environment, tags, metadata_filter, has_capacity, alias_contains]
|
|
345
|
+
):
|
|
346
|
+
# Resolve enums
|
|
347
|
+
if isinstance(provider, str):
|
|
348
|
+
provider = Provider.from_string(provider)
|
|
349
|
+
if isinstance(status, str):
|
|
350
|
+
status = KeyStatus(status)
|
|
351
|
+
if isinstance(environment, str):
|
|
352
|
+
environment = Environment(environment)
|
|
353
|
+
|
|
354
|
+
key_filter = KeyFilter(
|
|
355
|
+
provider=provider,
|
|
356
|
+
status=status,
|
|
357
|
+
environment=environment,
|
|
358
|
+
tags=tags,
|
|
359
|
+
metadata_filter=metadata_filter,
|
|
360
|
+
has_capacity=has_capacity,
|
|
361
|
+
alias_contains=alias_contains,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
keys = await self._storage.get_all_keys(key_filter)
|
|
365
|
+
if decrypt and self._config.encrypt_keys and self._config.shared_secret:
|
|
366
|
+
for key in keys:
|
|
367
|
+
key.key_value = decrypt_api_key(key.key_value, self._config.shared_secret)
|
|
368
|
+
return keys
|
|
369
|
+
|
|
370
|
+
async def update_key(
|
|
371
|
+
self,
|
|
372
|
+
key_id: str,
|
|
373
|
+
alias: Optional[str] = None,
|
|
374
|
+
status: Optional[KeyStatus | str] = None,
|
|
375
|
+
daily_limit: Optional[int] = None,
|
|
376
|
+
monthly_limit: Optional[int] = None,
|
|
377
|
+
max_concurrent: Optional[int] = None,
|
|
378
|
+
environment: Optional[Environment | str] = None,
|
|
379
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
380
|
+
tags: Optional[list[str]] = None,
|
|
381
|
+
expires_at: Optional[datetime] = None,
|
|
382
|
+
weight: Optional[int] = None,
|
|
383
|
+
priority: Optional[int] = None,
|
|
384
|
+
) -> APIKey:
|
|
385
|
+
"""Update an existing key's fields.
|
|
386
|
+
|
|
387
|
+
Only provided (non-None) fields are updated.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
key_id: The key to update.
|
|
391
|
+
alias: New alias.
|
|
392
|
+
status: New status.
|
|
393
|
+
daily_limit: New daily limit.
|
|
394
|
+
monthly_limit: New monthly limit.
|
|
395
|
+
max_concurrent: New max concurrent.
|
|
396
|
+
environment: New environment.
|
|
397
|
+
metadata: New metadata (replaces existing).
|
|
398
|
+
tags: New tags (replaces existing).
|
|
399
|
+
expires_at: New expiration.
|
|
400
|
+
weight: New weight.
|
|
401
|
+
priority: New priority.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
The updated APIKey.
|
|
405
|
+
"""
|
|
406
|
+
self._ensure_initialized()
|
|
407
|
+
|
|
408
|
+
kwargs = {}
|
|
409
|
+
if alias is not None: kwargs["alias"] = alias
|
|
410
|
+
if status is not None: kwargs["status"] = KeyStatus(status) if isinstance(status, str) else status
|
|
411
|
+
if daily_limit is not None: kwargs["daily_limit"] = daily_limit
|
|
412
|
+
if monthly_limit is not None: kwargs["monthly_limit"] = monthly_limit
|
|
413
|
+
if max_concurrent is not None: kwargs["max_concurrent"] = max_concurrent
|
|
414
|
+
if environment is not None: kwargs["environment"] = Environment(environment) if isinstance(environment, str) else environment
|
|
415
|
+
if metadata is not None: kwargs["metadata"] = metadata
|
|
416
|
+
if tags is not None: kwargs["tags"] = tags
|
|
417
|
+
if expires_at is not None: kwargs["expires_at"] = expires_at
|
|
418
|
+
if weight is not None: kwargs["weight"] = weight
|
|
419
|
+
if priority is not None: kwargs["priority"] = priority
|
|
420
|
+
|
|
421
|
+
req = KeyUpdateRequest(**kwargs)
|
|
422
|
+
|
|
423
|
+
return await self._storage.update_key(key_id, req)
|
|
424
|
+
|
|
425
|
+
async def delete_key(self, key_id: str, hard: bool = False) -> bool:
|
|
426
|
+
"""Delete a key.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
key_id: The key to delete.
|
|
430
|
+
hard: If True, permanently delete. If False, soft delete (mark as REVOKED).
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
True if the key was found and deleted.
|
|
434
|
+
"""
|
|
435
|
+
self._ensure_initialized()
|
|
436
|
+
soft = not hard and self._config.soft_delete
|
|
437
|
+
return await self._storage.delete_key(key_id, soft=soft)
|
|
438
|
+
|
|
439
|
+
# ── Key Selection ─────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
async def get_next_key(
|
|
442
|
+
self,
|
|
443
|
+
provider: Provider | str,
|
|
444
|
+
environment: Optional[Environment | str] = None,
|
|
445
|
+
tags: Optional[list[str]] = None,
|
|
446
|
+
metadata_filter: Optional[dict[str, Any]] = None,
|
|
447
|
+
alias_contains: Optional[str] = None,
|
|
448
|
+
decrypt: bool = True,
|
|
449
|
+
) -> APIKey:
|
|
450
|
+
"""Get the next available key for a provider using the rotation strategy.
|
|
451
|
+
|
|
452
|
+
Automatically handles:
|
|
453
|
+
- Counter resets (daily/monthly)
|
|
454
|
+
- Skipping rate-limited, expired, or inactive keys
|
|
455
|
+
- Round-robin, least-used, random, or weighted selection
|
|
456
|
+
- Filtering by environment, tags, metadata, and alias
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
provider: The API provider.
|
|
460
|
+
environment: Optional environment filter.
|
|
461
|
+
tags: Optional tags filter.
|
|
462
|
+
metadata_filter: Optional metadata filter.
|
|
463
|
+
alias_contains: Optional alias substring filter.
|
|
464
|
+
decrypt: If True, decrypt the key value.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
The selected APIKey ready for use.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
NoAvailableKeyError: If no suitable key is available.
|
|
471
|
+
"""
|
|
472
|
+
self._ensure_initialized()
|
|
473
|
+
|
|
474
|
+
if isinstance(provider, str):
|
|
475
|
+
provider = Provider.from_string(provider)
|
|
476
|
+
|
|
477
|
+
# Get all keys for the provider matching filters
|
|
478
|
+
keys = await self.get_all_keys(
|
|
479
|
+
provider=provider,
|
|
480
|
+
environment=environment,
|
|
481
|
+
tags=tags,
|
|
482
|
+
metadata_filter=metadata_filter,
|
|
483
|
+
alias_contains=alias_contains,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Auto-reset stale counters
|
|
487
|
+
if self._rate_limiter:
|
|
488
|
+
refreshed_keys = []
|
|
489
|
+
for key in keys:
|
|
490
|
+
key = await self._rate_limiter.check_and_update(key)
|
|
491
|
+
refreshed_keys.append(key)
|
|
492
|
+
keys = refreshed_keys
|
|
493
|
+
|
|
494
|
+
# Use rotator to select the best key
|
|
495
|
+
selected = self._rotator.select_key(keys, provider)
|
|
496
|
+
|
|
497
|
+
if decrypt and self._config.encrypt_keys and self._config.shared_secret:
|
|
498
|
+
selected.key_value = decrypt_api_key(
|
|
499
|
+
selected.key_value, self._config.shared_secret
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
return selected
|
|
503
|
+
|
|
504
|
+
@asynccontextmanager
|
|
505
|
+
async def use_key(
|
|
506
|
+
self,
|
|
507
|
+
provider: Provider | str,
|
|
508
|
+
environment: Optional[Environment | str] = None,
|
|
509
|
+
tags: Optional[list[str]] = None,
|
|
510
|
+
metadata_filter: Optional[dict[str, Any]] = None,
|
|
511
|
+
alias_contains: Optional[str] = None,
|
|
512
|
+
decrypt: bool = True,
|
|
513
|
+
) -> AsyncIterator[APIKey]:
|
|
514
|
+
"""Context manager for using a key with automatic lifecycle management.
|
|
515
|
+
|
|
516
|
+
Selects the best available key, acquires a concurrent slot, yields the key,
|
|
517
|
+
then releases the slot and records usage on successful completion.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
provider: The API provider.
|
|
521
|
+
environment: Optional environment filter.
|
|
522
|
+
tags: Optional tags filter.
|
|
523
|
+
metadata_filter: Optional metadata filter.
|
|
524
|
+
alias_contains: Optional alias substring filter.
|
|
525
|
+
decrypt: If True, decrypt the key value.
|
|
526
|
+
|
|
527
|
+
Yields:
|
|
528
|
+
The selected APIKey ready for use.
|
|
529
|
+
|
|
530
|
+
Example::
|
|
531
|
+
|
|
532
|
+
async with handler.use_key(Provider.OPENAI) as key:
|
|
533
|
+
response = await call_openai_api(key.key_value)
|
|
534
|
+
# concurrent_usage decremented, usage recorded
|
|
535
|
+
"""
|
|
536
|
+
self._ensure_initialized()
|
|
537
|
+
|
|
538
|
+
# Select the next key
|
|
539
|
+
key = await self.get_next_key(
|
|
540
|
+
provider=provider,
|
|
541
|
+
environment=environment,
|
|
542
|
+
tags=tags,
|
|
543
|
+
metadata_filter=metadata_filter,
|
|
544
|
+
alias_contains=alias_contains,
|
|
545
|
+
decrypt=decrypt,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Acquire concurrent slot and manage lifecycle
|
|
549
|
+
assert self._usage_tracker is not None
|
|
550
|
+
async with self._usage_tracker.use(key.id, record=True) as used_key:
|
|
551
|
+
# Replace the key value with decrypted version if needed
|
|
552
|
+
if decrypt and self._config.encrypt_keys and self._config.shared_secret:
|
|
553
|
+
used_key.key_value = decrypt_api_key(
|
|
554
|
+
used_key.key_value, self._config.shared_secret
|
|
555
|
+
)
|
|
556
|
+
yield used_key
|
|
557
|
+
|
|
558
|
+
# ── Usage & Stats ─────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
async def record_usage(self, key_id: str, count: int = 1) -> None:
|
|
561
|
+
"""Record usage for a specific key.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
key_id: The key to record usage for.
|
|
565
|
+
count: Number of uses to record.
|
|
566
|
+
"""
|
|
567
|
+
self._ensure_initialized()
|
|
568
|
+
assert self._usage_tracker is not None
|
|
569
|
+
await self._usage_tracker.record_usage(key_id, count)
|
|
570
|
+
|
|
571
|
+
async def get_usage_stats(self, key_id: str) -> UsageStats:
|
|
572
|
+
"""Get usage statistics for a key.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
key_id: The key to get stats for.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
UsageStats with all usage information.
|
|
579
|
+
"""
|
|
580
|
+
self._ensure_initialized()
|
|
581
|
+
assert self._usage_tracker is not None
|
|
582
|
+
return await self._usage_tracker.get_usage_stats(key_id)
|
|
583
|
+
|
|
584
|
+
async def get_concurrent_usage(self, key_id: str) -> int:
|
|
585
|
+
"""Get current concurrent usage for a key.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
key_id: The key to check.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Current number of concurrent uses.
|
|
592
|
+
"""
|
|
593
|
+
self._ensure_initialized()
|
|
594
|
+
assert self._usage_tracker is not None
|
|
595
|
+
return await self._usage_tracker.get_concurrent_usage(key_id)
|
|
596
|
+
|
|
597
|
+
async def get_provider_stats(self, provider: Provider | str) -> list[UsageStats]:
|
|
598
|
+
"""Get usage stats for all keys of a provider.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
provider: The provider to get stats for.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
List of UsageStats for all keys of the provider.
|
|
605
|
+
"""
|
|
606
|
+
self._ensure_initialized()
|
|
607
|
+
assert self._usage_tracker is not None
|
|
608
|
+
return await self._usage_tracker.get_provider_stats(provider)
|
|
609
|
+
|
|
610
|
+
# ── Bulk Operations ───────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
async def bulk_add_keys(
|
|
613
|
+
self,
|
|
614
|
+
keys: list[dict[str, Any] | KeyCreateRequest],
|
|
615
|
+
) -> BulkOperationResult:
|
|
616
|
+
"""Add multiple keys at once.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
keys: List of key data (dicts or KeyCreateRequest objects).
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
BulkOperationResult with success/failure counts.
|
|
623
|
+
"""
|
|
624
|
+
self._ensure_initialized()
|
|
625
|
+
|
|
626
|
+
api_keys = []
|
|
627
|
+
for key_data in keys:
|
|
628
|
+
if isinstance(key_data, dict):
|
|
629
|
+
key_data = KeyCreateRequest(**key_data)
|
|
630
|
+
|
|
631
|
+
# Resolve provider
|
|
632
|
+
provider = key_data.provider
|
|
633
|
+
if isinstance(provider, str):
|
|
634
|
+
provider = Provider.from_string(provider)
|
|
635
|
+
|
|
636
|
+
# Encrypt if configured
|
|
637
|
+
key_value = key_data.key_value
|
|
638
|
+
if self._config.encrypt_keys and self._config.shared_secret:
|
|
639
|
+
key_value = encrypt_api_key(key_value, self._config.shared_secret)
|
|
640
|
+
|
|
641
|
+
api_key = APIKey(
|
|
642
|
+
provider=provider,
|
|
643
|
+
key_value=key_value,
|
|
644
|
+
alias=key_data.alias,
|
|
645
|
+
daily_limit=key_data.daily_limit if key_data.daily_limit is not None else self._config.default_daily_limit,
|
|
646
|
+
monthly_limit=key_data.monthly_limit if key_data.monthly_limit is not None else self._config.default_monthly_limit,
|
|
647
|
+
max_concurrent=key_data.max_concurrent if key_data.max_concurrent is not None else self._config.default_max_concurrent,
|
|
648
|
+
environment=key_data.environment,
|
|
649
|
+
metadata=key_data.metadata,
|
|
650
|
+
tags=key_data.tags,
|
|
651
|
+
expires_at=key_data.expires_at,
|
|
652
|
+
weight=key_data.weight,
|
|
653
|
+
priority=key_data.priority,
|
|
654
|
+
)
|
|
655
|
+
api_keys.append(api_key)
|
|
656
|
+
|
|
657
|
+
return await self._storage.bulk_add_keys(api_keys)
|
|
658
|
+
|
|
659
|
+
async def bulk_delete_keys(
|
|
660
|
+
self,
|
|
661
|
+
key_ids: list[str],
|
|
662
|
+
hard: bool = False,
|
|
663
|
+
) -> BulkOperationResult:
|
|
664
|
+
"""Delete multiple keys at once.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
key_ids: List of key IDs to delete.
|
|
668
|
+
hard: If True, permanently delete.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
BulkOperationResult with success/failure counts.
|
|
672
|
+
"""
|
|
673
|
+
self._ensure_initialized()
|
|
674
|
+
soft = not hard and self._config.soft_delete
|
|
675
|
+
return await self._storage.bulk_delete_keys(key_ids, soft=soft)
|
|
676
|
+
|
|
677
|
+
# ── Health & Info ─────────────────────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
async def health_check(self) -> dict[str, Any]:
|
|
680
|
+
"""Check if the handler and storage are healthy.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Dict with health status information.
|
|
684
|
+
"""
|
|
685
|
+
storage_ok = False
|
|
686
|
+
if self._initialized:
|
|
687
|
+
try:
|
|
688
|
+
storage_ok = await self._storage.health_check()
|
|
689
|
+
except Exception:
|
|
690
|
+
storage_ok = False
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
"initialized": self._initialized,
|
|
694
|
+
"storage_backend": self._config.storage_backend,
|
|
695
|
+
"storage_healthy": storage_ok,
|
|
696
|
+
"encryption_enabled": self._config.encrypt_keys,
|
|
697
|
+
"rotation_strategy": self._config.rotation_strategy,
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async def info(self) -> dict[str, Any]:
|
|
701
|
+
"""Get summary information about the handler.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
Dict with key counts, configuration, and status.
|
|
705
|
+
"""
|
|
706
|
+
self._ensure_initialized()
|
|
707
|
+
|
|
708
|
+
total_keys = await self._storage.count_keys()
|
|
709
|
+
active_keys = await self._storage.count_keys(status=KeyStatus.ACTIVE)
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
"total_keys": total_keys,
|
|
713
|
+
"active_keys": active_keys,
|
|
714
|
+
"storage_backend": self._config.storage_backend,
|
|
715
|
+
"rotation_strategy": self._config.rotation_strategy,
|
|
716
|
+
"encryption_enabled": self._config.encrypt_keys,
|
|
717
|
+
"auto_reset_counters": self._config.auto_reset_counters,
|
|
718
|
+
"soft_delete": self._config.soft_delete,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
# ── Manual Resets ─────────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
async def reset_daily_counts(self) -> int:
|
|
724
|
+
"""Manually reset all stale daily counters.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
Number of keys reset.
|
|
728
|
+
"""
|
|
729
|
+
self._ensure_initialized()
|
|
730
|
+
assert self._rate_limiter is not None
|
|
731
|
+
return await self._rate_limiter.reset_daily()
|
|
732
|
+
|
|
733
|
+
async def reset_monthly_counts(self) -> int:
|
|
734
|
+
"""Manually reset all stale monthly counters.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
Number of keys reset.
|
|
738
|
+
"""
|
|
739
|
+
self._ensure_initialized()
|
|
740
|
+
assert self._rate_limiter is not None
|
|
741
|
+
return await self._rate_limiter.reset_monthly()
|
|
742
|
+
|
|
743
|
+
async def reset_all_concurrent(self) -> int:
|
|
744
|
+
"""Reset concurrent usage to 0 for all keys.
|
|
745
|
+
|
|
746
|
+
Useful for application startup recovery after crashes.
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
Number of keys reset.
|
|
750
|
+
"""
|
|
751
|
+
self._ensure_initialized()
|
|
752
|
+
assert self._usage_tracker is not None
|
|
753
|
+
return await self._usage_tracker.reset_all_concurrent()
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
class SyncAPIServiceHandler:
|
|
757
|
+
"""Synchronous wrapper around APIServiceHandler.
|
|
758
|
+
|
|
759
|
+
Provides the same API but runs everything through asyncio.run() or
|
|
760
|
+
an existing event loop. Useful for non-async codebases.
|
|
761
|
+
|
|
762
|
+
Example::
|
|
763
|
+
|
|
764
|
+
handler = SyncAPIServiceHandler(
|
|
765
|
+
storage_backend="sqlite",
|
|
766
|
+
connection_string="sqlite:///keys.db",
|
|
767
|
+
)
|
|
768
|
+
handler.initialize()
|
|
769
|
+
|
|
770
|
+
key = handler.add_key(provider="openai", key_value="sk-abc...")
|
|
771
|
+
handler.close()
|
|
772
|
+
"""
|
|
773
|
+
|
|
774
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
775
|
+
self._async_handler = APIServiceHandler(**kwargs)
|
|
776
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
777
|
+
|
|
778
|
+
def _run(self, coro):
|
|
779
|
+
"""Run an async coroutine synchronously."""
|
|
780
|
+
try:
|
|
781
|
+
loop = asyncio.get_running_loop()
|
|
782
|
+
except RuntimeError:
|
|
783
|
+
loop = None
|
|
784
|
+
|
|
785
|
+
if loop and loop.is_running():
|
|
786
|
+
# We're inside an async context — use a new thread
|
|
787
|
+
import concurrent.futures
|
|
788
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
789
|
+
future = pool.submit(asyncio.run, coro)
|
|
790
|
+
return future.result()
|
|
791
|
+
else:
|
|
792
|
+
return asyncio.run(coro)
|
|
793
|
+
|
|
794
|
+
def initialize(self) -> None:
|
|
795
|
+
"""Initialize the handler."""
|
|
796
|
+
self._run(self._async_handler.initialize())
|
|
797
|
+
|
|
798
|
+
def close(self) -> None:
|
|
799
|
+
"""Close the handler."""
|
|
800
|
+
self._run(self._async_handler.close())
|
|
801
|
+
|
|
802
|
+
def add_key(self, **kwargs) -> APIKey:
|
|
803
|
+
"""Add a new API key."""
|
|
804
|
+
return self._run(self._async_handler.add_key(**kwargs))
|
|
805
|
+
|
|
806
|
+
def get_key(self, key_id: str, **kwargs) -> APIKey:
|
|
807
|
+
"""Get a key by ID."""
|
|
808
|
+
return self._run(self._async_handler.get_key(key_id, **kwargs))
|
|
809
|
+
|
|
810
|
+
def get_keys_by_provider(self, provider, **kwargs) -> list[APIKey]:
|
|
811
|
+
"""Get all keys for a provider."""
|
|
812
|
+
return self._run(self._async_handler.get_keys_by_provider(provider, **kwargs))
|
|
813
|
+
|
|
814
|
+
def get_all_keys(self, **kwargs) -> list[APIKey]:
|
|
815
|
+
"""Get all keys with optional filtering."""
|
|
816
|
+
return self._run(self._async_handler.get_all_keys(**kwargs))
|
|
817
|
+
|
|
818
|
+
def update_key(self, key_id: str, **kwargs) -> APIKey:
|
|
819
|
+
"""Update a key."""
|
|
820
|
+
return self._run(self._async_handler.update_key(key_id, **kwargs))
|
|
821
|
+
|
|
822
|
+
def delete_key(self, key_id: str, **kwargs) -> bool:
|
|
823
|
+
"""Delete a key."""
|
|
824
|
+
return self._run(self._async_handler.delete_key(key_id, **kwargs))
|
|
825
|
+
|
|
826
|
+
def get_next_key(self, provider, **kwargs) -> APIKey:
|
|
827
|
+
"""Get the next available key."""
|
|
828
|
+
return self._run(self._async_handler.get_next_key(provider, **kwargs))
|
|
829
|
+
|
|
830
|
+
def record_usage(self, key_id: str, **kwargs) -> None:
|
|
831
|
+
"""Record usage for a key."""
|
|
832
|
+
self._run(self._async_handler.record_usage(key_id, **kwargs))
|
|
833
|
+
|
|
834
|
+
def get_usage_stats(self, key_id: str) -> UsageStats:
|
|
835
|
+
"""Get usage stats for a key."""
|
|
836
|
+
return self._run(self._async_handler.get_usage_stats(key_id))
|
|
837
|
+
|
|
838
|
+
def get_concurrent_usage(self, key_id: str) -> int:
|
|
839
|
+
"""Get concurrent usage."""
|
|
840
|
+
return self._run(self._async_handler.get_concurrent_usage(key_id))
|
|
841
|
+
|
|
842
|
+
def health_check(self) -> dict[str, Any]:
|
|
843
|
+
"""Health check."""
|
|
844
|
+
return self._run(self._async_handler.health_check())
|
|
845
|
+
|
|
846
|
+
def info(self) -> dict[str, Any]:
|
|
847
|
+
"""Get handler info."""
|
|
848
|
+
return self._run(self._async_handler.info())
|
|
849
|
+
|
|
850
|
+
def bulk_add_keys(self, keys, **kwargs) -> BulkOperationResult:
|
|
851
|
+
"""Bulk add keys."""
|
|
852
|
+
return self._run(self._async_handler.bulk_add_keys(keys, **kwargs))
|
|
853
|
+
|
|
854
|
+
def bulk_delete_keys(self, key_ids, **kwargs) -> BulkOperationResult:
|
|
855
|
+
"""Bulk delete keys."""
|
|
856
|
+
return self._run(self._async_handler.bulk_delete_keys(key_ids, **kwargs))
|
|
857
|
+
|
|
858
|
+
def reset_daily_counts(self) -> int:
|
|
859
|
+
"""Reset daily counts."""
|
|
860
|
+
return self._run(self._async_handler.reset_daily_counts())
|
|
861
|
+
|
|
862
|
+
def reset_monthly_counts(self) -> int:
|
|
863
|
+
"""Reset monthly counts."""
|
|
864
|
+
return self._run(self._async_handler.reset_monthly_counts())
|
|
865
|
+
|
|
866
|
+
def reset_all_concurrent(self) -> int:
|
|
867
|
+
"""Reset all concurrent usage."""
|
|
868
|
+
return self._run(self._async_handler.reset_all_concurrent())
|