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,177 @@
|
|
|
1
|
+
"""Configuration module for the API Service Handler.
|
|
2
|
+
|
|
3
|
+
Provides :class:`ASHConfig` — a dataclass holding every tuneable knob — and
|
|
4
|
+
the helper :func:`get_config_from_env` that builds an ``ASHConfig`` purely
|
|
5
|
+
from environment variables prefixed with ``ASH_``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Supported literal values (kept as module-level constants for validation)
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
VALID_STORAGE_BACKENDS = frozenset({"memory", "sqlite", "mongodb", "postgresql"})
|
|
20
|
+
VALID_ROTATION_STRATEGIES = frozenset({"round_robin", "least_used", "random", "weighted"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Core configuration dataclass
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ASHConfig:
|
|
30
|
+
"""Configuration for the API Service Handler.
|
|
31
|
+
|
|
32
|
+
Attributes
|
|
33
|
+
----------
|
|
34
|
+
storage_backend:
|
|
35
|
+
Which persistence layer to use. One of ``memory``, ``sqlite``,
|
|
36
|
+
``mongodb``, or ``postgresql``. Falls back to the
|
|
37
|
+
``ASH_STORAGE_BACKEND`` env var when set to ``"memory"`` (the default).
|
|
38
|
+
connection_string:
|
|
39
|
+
Database connection URI. Falls back to ``ASH_CONNECTION_STRING``.
|
|
40
|
+
shared_secret:
|
|
41
|
+
Passphrase used to derive an AES-256-GCM key for encrypting stored API
|
|
42
|
+
keys. Falls back to ``ASH_SHARED_SECRET``.
|
|
43
|
+
encrypt_keys:
|
|
44
|
+
Whether API key values should be encrypted at rest.
|
|
45
|
+
rotation_strategy:
|
|
46
|
+
Algorithm used when picking the next key. One of ``round_robin``,
|
|
47
|
+
``least_used``, ``random``, or ``weighted``.
|
|
48
|
+
auto_reset_counters:
|
|
49
|
+
Automatically reset daily / monthly usage counters on access when the
|
|
50
|
+
current period has elapsed.
|
|
51
|
+
soft_delete:
|
|
52
|
+
When ``True``, deleting a key sets its status to ``REVOKED`` rather
|
|
53
|
+
than removing the record.
|
|
54
|
+
default_daily_limit:
|
|
55
|
+
Default daily request cap applied to newly created keys.
|
|
56
|
+
default_monthly_limit:
|
|
57
|
+
Default monthly request cap applied to newly created keys.
|
|
58
|
+
default_max_concurrent:
|
|
59
|
+
Default maximum number of concurrent in-flight requests per key.
|
|
60
|
+
metadata_indexes:
|
|
61
|
+
List of metadata keys to create compound indexes for (e.g. ['client_id']).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# Storage
|
|
65
|
+
storage_backend: str = "memory"
|
|
66
|
+
connection_string: str = ""
|
|
67
|
+
|
|
68
|
+
# Encryption
|
|
69
|
+
shared_secret: str = ""
|
|
70
|
+
encrypt_keys: bool = True
|
|
71
|
+
|
|
72
|
+
# Rotation
|
|
73
|
+
rotation_strategy: str = "round_robin"
|
|
74
|
+
|
|
75
|
+
# Behaviour
|
|
76
|
+
auto_reset_counters: bool = True
|
|
77
|
+
soft_delete: bool = True
|
|
78
|
+
default_daily_limit: Optional[int] = None
|
|
79
|
+
default_monthly_limit: Optional[int] = None
|
|
80
|
+
default_max_concurrent: Optional[int] = None
|
|
81
|
+
metadata_indexes: list[str] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Post-init: resolve env-var fallbacks & validate
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
# Env-var fallbacks
|
|
89
|
+
if not self.shared_secret:
|
|
90
|
+
self.shared_secret = os.environ.get("ASH_SHARED_SECRET", "")
|
|
91
|
+
if not self.connection_string:
|
|
92
|
+
self.connection_string = os.environ.get("ASH_CONNECTION_STRING", "")
|
|
93
|
+
if self.storage_backend == "memory":
|
|
94
|
+
self.storage_backend = os.environ.get("ASH_STORAGE_BACKEND", "memory")
|
|
95
|
+
|
|
96
|
+
# Normalise & validate
|
|
97
|
+
self.storage_backend = self.storage_backend.lower().strip()
|
|
98
|
+
self.rotation_strategy = self.rotation_strategy.lower().strip()
|
|
99
|
+
|
|
100
|
+
if self.storage_backend not in VALID_STORAGE_BACKENDS:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Invalid storage_backend {self.storage_backend!r}. "
|
|
103
|
+
f"Choose from {sorted(VALID_STORAGE_BACKENDS)}."
|
|
104
|
+
)
|
|
105
|
+
if self.rotation_strategy not in VALID_ROTATION_STRATEGIES:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Invalid rotation_strategy {self.rotation_strategy!r}. "
|
|
108
|
+
f"Choose from {sorted(VALID_ROTATION_STRATEGIES)}."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Limit fields must be non-negative when set
|
|
112
|
+
for attr in ("default_daily_limit", "default_monthly_limit", "default_max_concurrent"):
|
|
113
|
+
value = getattr(self, attr)
|
|
114
|
+
if value is not None and value < 0:
|
|
115
|
+
raise ValueError(f"{attr} must be a non-negative integer, got {value}.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Factory – build config entirely from environment variables
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
124
|
+
"""Read an env var as a boolean (``true/1/yes`` → True)."""
|
|
125
|
+
raw = os.environ.get(name)
|
|
126
|
+
if raw is None:
|
|
127
|
+
return default
|
|
128
|
+
return raw.strip().lower() in {"true", "1", "yes"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _env_optional_int(name: str) -> Optional[int]:
|
|
132
|
+
"""Read an env var as an optional integer."""
|
|
133
|
+
raw = os.environ.get(name)
|
|
134
|
+
if raw is None or raw.strip() == "":
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
return int(raw)
|
|
138
|
+
except ValueError:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Environment variable {name} must be an integer, got {raw!r}."
|
|
141
|
+
) from None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_config_from_env() -> ASHConfig:
|
|
145
|
+
"""Build an :class:`ASHConfig` entirely from ``ASH_*`` environment variables.
|
|
146
|
+
|
|
147
|
+
Recognised variables
|
|
148
|
+
--------------------
|
|
149
|
+
* ``ASH_STORAGE_BACKEND`` – storage backend name
|
|
150
|
+
* ``ASH_CONNECTION_STRING`` – database connection URI
|
|
151
|
+
* ``ASH_SHARED_SECRET`` – encryption passphrase
|
|
152
|
+
* ``ASH_ENCRYPT_KEYS`` – ``true`` / ``false``
|
|
153
|
+
* ``ASH_ROTATION_STRATEGY`` – rotation algorithm name
|
|
154
|
+
* ``ASH_AUTO_RESET_COUNTERS`` – ``true`` / ``false``
|
|
155
|
+
* ``ASH_SOFT_DELETE`` – ``true`` / ``false``
|
|
156
|
+
* ``ASH_DEFAULT_DAILY_LIMIT``
|
|
157
|
+
* ``ASH_DEFAULT_MONTHLY_LIMIT``
|
|
158
|
+
* ``ASH_DEFAULT_MAX_CONCURRENT``
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
ASHConfig
|
|
163
|
+
A fully populated configuration instance.
|
|
164
|
+
"""
|
|
165
|
+
return ASHConfig(
|
|
166
|
+
storage_backend=os.environ.get("ASH_STORAGE_BACKEND", "memory"),
|
|
167
|
+
connection_string=os.environ.get("ASH_CONNECTION_STRING", ""),
|
|
168
|
+
shared_secret=os.environ.get("ASH_SHARED_SECRET", ""),
|
|
169
|
+
encrypt_keys=_env_bool("ASH_ENCRYPT_KEYS", default=True),
|
|
170
|
+
rotation_strategy=os.environ.get("ASH_ROTATION_STRATEGY", "round_robin"),
|
|
171
|
+
auto_reset_counters=_env_bool("ASH_AUTO_RESET_COUNTERS", default=True),
|
|
172
|
+
soft_delete=_env_bool("ASH_SOFT_DELETE", default=True),
|
|
173
|
+
default_daily_limit=_env_optional_int("ASH_DEFAULT_DAILY_LIMIT"),
|
|
174
|
+
default_monthly_limit=_env_optional_int("ASH_DEFAULT_MONTHLY_LIMIT"),
|
|
175
|
+
default_max_concurrent=_env_optional_int("ASH_DEFAULT_MAX_CONCURRENT"),
|
|
176
|
+
metadata_indexes=[x.strip() for x in os.environ.get("ASH_METADATA_INDEXES", "").split(",")] if os.environ.get("ASH_METADATA_INDEXES") else [],
|
|
177
|
+
)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""AES-256-GCM encryption utilities for the API Service Handler.
|
|
2
|
+
|
|
3
|
+
API key values are encrypted at rest using a symmetric key derived from a
|
|
4
|
+
shared secret. The encrypted representation is a dot-separated triple of
|
|
5
|
+
Base-64 segments::
|
|
6
|
+
|
|
7
|
+
<iv_b64>.<auth_tag_b64>.<ciphertext_b64>
|
|
8
|
+
|
|
9
|
+
This format is deterministic enough to be detected by :func:`is_encrypted`
|
|
10
|
+
yet safe against accidental double-encryption because plain API keys never
|
|
11
|
+
contain two dots separating valid Base-64 segments.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
24
|
+
|
|
25
|
+
from .exceptions import EncryptionError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Constants
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
_IV_BYTES: int = 12 # 96-bit nonce recommended for AES-GCM
|
|
33
|
+
_TAG_BYTES: int = 16 # 128-bit authentication tag
|
|
34
|
+
_KEY_BYTES: int = 32 # 256-bit key (AES-256)
|
|
35
|
+
|
|
36
|
+
# Loose regex for a Base-64 segment (standard or URL-safe, with optional padding)
|
|
37
|
+
_B64_SEGMENT = r"[A-Za-z0-9+/\-_]+=*"
|
|
38
|
+
_ENCRYPTED_PATTERN: re.Pattern[str] = re.compile(
|
|
39
|
+
rf"^{_B64_SEGMENT}\.{_B64_SEGMENT}\.{_B64_SEGMENT}$"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Internal helpers
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _derive_key(shared_secret: str) -> bytes:
|
|
49
|
+
"""Derive a 256-bit AES key from *shared_secret* via SHA-256.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
shared_secret:
|
|
54
|
+
The passphrase to derive the key from. Must be non-empty.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
bytes
|
|
59
|
+
A 32-byte key suitable for ``AESGCM``.
|
|
60
|
+
|
|
61
|
+
Raises
|
|
62
|
+
------
|
|
63
|
+
ValueError
|
|
64
|
+
If *shared_secret* is empty.
|
|
65
|
+
"""
|
|
66
|
+
if not shared_secret:
|
|
67
|
+
raise ValueError("shared_secret must not be empty for encryption/decryption.")
|
|
68
|
+
return hashlib.sha256(shared_secret.encode("utf-8")).digest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _b64_encode(data: bytes) -> str:
|
|
72
|
+
"""Return standard Base-64 encoding of *data* as a UTF-8 string."""
|
|
73
|
+
return base64.b64encode(data).decode("utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _b64_decode(data: str) -> bytes:
|
|
77
|
+
"""Decode a standard Base-64 string to bytes."""
|
|
78
|
+
return base64.b64decode(data)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Public API – encrypt / decrypt
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def encrypt_api_key(plain_text: str, shared_secret: str) -> str:
|
|
87
|
+
"""Encrypt *plain_text* with AES-256-GCM using a key derived from *shared_secret*.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
plain_text:
|
|
92
|
+
The raw API key (or any secret) to encrypt.
|
|
93
|
+
shared_secret:
|
|
94
|
+
Passphrase used to derive the encryption key.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
str
|
|
99
|
+
The encrypted payload formatted as ``iv_b64.auth_tag_b64.ciphertext_b64``.
|
|
100
|
+
|
|
101
|
+
Raises
|
|
102
|
+
------
|
|
103
|
+
ValueError
|
|
104
|
+
If *shared_secret* is empty.
|
|
105
|
+
"""
|
|
106
|
+
key = _derive_key(shared_secret)
|
|
107
|
+
iv = os.urandom(_IV_BYTES)
|
|
108
|
+
|
|
109
|
+
aesgcm = AESGCM(key)
|
|
110
|
+
# AESGCM.encrypt returns ciphertext || tag (tag is the last 16 bytes)
|
|
111
|
+
ciphertext_with_tag: bytes = aesgcm.encrypt(iv, plain_text.encode("utf-8"), None)
|
|
112
|
+
|
|
113
|
+
# Split into ciphertext body + authentication tag
|
|
114
|
+
ciphertext = ciphertext_with_tag[:-_TAG_BYTES]
|
|
115
|
+
auth_tag = ciphertext_with_tag[-_TAG_BYTES:]
|
|
116
|
+
|
|
117
|
+
return f"{_b64_encode(iv)}.{_b64_encode(auth_tag)}.{_b64_encode(ciphertext)}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def decrypt_api_key(encrypted_str: str, shared_secret: str) -> str:
|
|
121
|
+
"""Decrypt an encrypted payload produced by :func:`encrypt_api_key`.
|
|
122
|
+
|
|
123
|
+
Values that do not look like an encrypted triple (non-string, or fewer/more
|
|
124
|
+
than three dot-separated segments) are returned unchanged, so plain-text
|
|
125
|
+
keys pass through safely.
|
|
126
|
+
|
|
127
|
+
Values that *do* look encrypted but fail decryption (e.g. wrong secret,
|
|
128
|
+
tampered ciphertext) raise :class:`EncryptionError` — callers should not
|
|
129
|
+
silently swallow decryption failures, as they indicate a misconfiguration
|
|
130
|
+
or data integrity problem.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
encrypted_str:
|
|
135
|
+
The dot-separated encrypted payload, or a plain-text fallback.
|
|
136
|
+
shared_secret:
|
|
137
|
+
Passphrase used to derive the decryption key.
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
str
|
|
142
|
+
The decrypted plain text.
|
|
143
|
+
|
|
144
|
+
Raises
|
|
145
|
+
------
|
|
146
|
+
EncryptionError
|
|
147
|
+
If the value looks encrypted but decryption fails.
|
|
148
|
+
"""
|
|
149
|
+
# Guard: not a string → return as-is
|
|
150
|
+
if not isinstance(encrypted_str, str):
|
|
151
|
+
return encrypted_str # type: ignore[return-value]
|
|
152
|
+
|
|
153
|
+
# Guard: must be exactly three dot-separated segments to look encrypted
|
|
154
|
+
parts = encrypted_str.split(".")
|
|
155
|
+
if len(parts) != 3:
|
|
156
|
+
return encrypted_str
|
|
157
|
+
|
|
158
|
+
# Value matches the encrypted format — decrypt and raise on any failure.
|
|
159
|
+
try:
|
|
160
|
+
iv = _b64_decode(parts[0])
|
|
161
|
+
auth_tag = _b64_decode(parts[1])
|
|
162
|
+
ciphertext = _b64_decode(parts[2])
|
|
163
|
+
|
|
164
|
+
key = _derive_key(shared_secret)
|
|
165
|
+
aesgcm = AESGCM(key)
|
|
166
|
+
|
|
167
|
+
# Reconstruct the format AESGCM expects: ciphertext || tag
|
|
168
|
+
ciphertext_with_tag = ciphertext + auth_tag
|
|
169
|
+
decrypted_bytes: bytes = aesgcm.decrypt(iv, ciphertext_with_tag, None)
|
|
170
|
+
|
|
171
|
+
# Try to deserialise as JSON first (handles stored dicts / lists)
|
|
172
|
+
try:
|
|
173
|
+
result: Any = json.loads(decrypted_bytes)
|
|
174
|
+
return str(result) if not isinstance(result, str) else result
|
|
175
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
176
|
+
return decrypted_bytes.decode("utf-8")
|
|
177
|
+
|
|
178
|
+
except EncryptionError:
|
|
179
|
+
raise
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
raise EncryptionError(
|
|
182
|
+
f"Decryption failed — verify shared_secret matches the one used for encryption: {exc}"
|
|
183
|
+
) from exc
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Public API – inspection helpers
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_encrypted(value: str) -> bool:
|
|
192
|
+
"""Check whether *value* looks like an encrypted payload.
|
|
193
|
+
|
|
194
|
+
This is a *heuristic* based on the ``iv.tag.ciphertext`` format — three
|
|
195
|
+
dot-separated Base-64 segments — and does **not** attempt actual
|
|
196
|
+
decryption.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
value:
|
|
201
|
+
The string to inspect.
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
bool
|
|
206
|
+
``True`` if *value* matches the encrypted format.
|
|
207
|
+
"""
|
|
208
|
+
if not isinstance(value, str):
|
|
209
|
+
return False
|
|
210
|
+
return bool(_ENCRYPTED_PATTERN.match(value))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def mask_key(key_value: str, show: int = 8) -> str:
|
|
214
|
+
"""Return a masked representation of *key_value* suitable for display.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
key_value:
|
|
219
|
+
The API key (plain text or encrypted) to mask.
|
|
220
|
+
show:
|
|
221
|
+
Number of leading characters to reveal. Defaults to ``8``.
|
|
222
|
+
|
|
223
|
+
Returns
|
|
224
|
+
-------
|
|
225
|
+
str
|
|
226
|
+
The first *show* characters followed by ``***``, or the full value
|
|
227
|
+
if it is shorter than or equal to *show* characters.
|
|
228
|
+
|
|
229
|
+
Examples
|
|
230
|
+
--------
|
|
231
|
+
>>> mask_key("sk-abc123def456ghi789")
|
|
232
|
+
'sk-abc12***'
|
|
233
|
+
>>> mask_key("short")
|
|
234
|
+
'short'
|
|
235
|
+
"""
|
|
236
|
+
if not isinstance(key_value, str) or len(key_value) <= show:
|
|
237
|
+
return key_value
|
|
238
|
+
return key_value[:show] + "***"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Enumerations for the api-service-handler library.
|
|
2
|
+
|
|
3
|
+
Defines all enum types used across the library including API providers,
|
|
4
|
+
key statuses, storage backends, rotation strategies, and environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Provider(str, Enum):
|
|
13
|
+
"""Supported API service providers.
|
|
14
|
+
|
|
15
|
+
Covers a wide range of provider categories including AI/LLM, speech/audio,
|
|
16
|
+
cloud infrastructure, communication, payments, search/data, auth,
|
|
17
|
+
storage/CDN, dev tools, messaging, productivity, monitoring, and maps.
|
|
18
|
+
|
|
19
|
+
Use ``Provider.from_string()`` for case-insensitive lookup with automatic
|
|
20
|
+
fallback to ``CUSTOM`` for unrecognised values.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# --- AI / LLM ---
|
|
24
|
+
OPENAI = "openai"
|
|
25
|
+
ANTHROPIC = "anthropic"
|
|
26
|
+
GOOGLE_GEMINI = "google_gemini"
|
|
27
|
+
GOOGLE_VERTEX = "google_vertex"
|
|
28
|
+
MISTRAL = "mistral"
|
|
29
|
+
COHERE = "cohere"
|
|
30
|
+
HUGGINGFACE = "huggingface"
|
|
31
|
+
REPLICATE = "replicate"
|
|
32
|
+
TOGETHER_AI = "together_ai"
|
|
33
|
+
GROQ = "groq"
|
|
34
|
+
FIREWORKS = "fireworks"
|
|
35
|
+
DEEPSEEK = "deepseek"
|
|
36
|
+
XAI = "xai"
|
|
37
|
+
PERPLEXITY = "perplexity"
|
|
38
|
+
OPENROUTER = "openrouter"
|
|
39
|
+
LEMOFOX = "lemofox"
|
|
40
|
+
|
|
41
|
+
# --- Speech / Audio ---
|
|
42
|
+
DEEPGRAM = "deepgram"
|
|
43
|
+
ELEVEN_LABS = "eleven_labs"
|
|
44
|
+
ASSEMBLY_AI = "assembly_ai"
|
|
45
|
+
WHISPER = "whisper"
|
|
46
|
+
|
|
47
|
+
# --- Cloud ---
|
|
48
|
+
AWS = "aws"
|
|
49
|
+
AZURE = "azure"
|
|
50
|
+
GCP = "gcp"
|
|
51
|
+
CLOUDFLARE = "cloudflare"
|
|
52
|
+
DIGITAL_OCEAN = "digital_ocean"
|
|
53
|
+
VERCEL = "vercel"
|
|
54
|
+
|
|
55
|
+
# --- Communication ---
|
|
56
|
+
TWILIO = "twilio"
|
|
57
|
+
SENDGRID = "sendgrid"
|
|
58
|
+
MAILGUN = "mailgun"
|
|
59
|
+
RESEND = "resend"
|
|
60
|
+
POSTMARK = "postmark"
|
|
61
|
+
|
|
62
|
+
# --- Payments ---
|
|
63
|
+
STRIPE = "stripe"
|
|
64
|
+
RAZORPAY = "razorpay"
|
|
65
|
+
PAYPAL = "paypal"
|
|
66
|
+
LEMONSQUEEZY = "lemonsqueezy"
|
|
67
|
+
|
|
68
|
+
# --- Search / Data ---
|
|
69
|
+
SERP_API = "serp_api"
|
|
70
|
+
BING_SEARCH = "bing_search"
|
|
71
|
+
ALGOLIA = "algolia"
|
|
72
|
+
PINECONE = "pinecone"
|
|
73
|
+
WEAVIATE = "weaviate"
|
|
74
|
+
|
|
75
|
+
# --- Auth ---
|
|
76
|
+
AUTH0 = "auth0"
|
|
77
|
+
CLERK = "clerk"
|
|
78
|
+
FIREBASE = "firebase"
|
|
79
|
+
SUPABASE_AUTH = "supabase_auth"
|
|
80
|
+
|
|
81
|
+
# --- Storage / CDN ---
|
|
82
|
+
CLOUDINARY = "cloudinary"
|
|
83
|
+
S3 = "s3"
|
|
84
|
+
SUPABASE = "supabase"
|
|
85
|
+
R2 = "r2"
|
|
86
|
+
UPLOADTHING = "uploadthing"
|
|
87
|
+
|
|
88
|
+
# --- Dev Tools ---
|
|
89
|
+
GITHUB = "github"
|
|
90
|
+
GITLAB = "gitlab"
|
|
91
|
+
BITBUCKET = "bitbucket"
|
|
92
|
+
LINEAR = "linear"
|
|
93
|
+
JIRA = "jira"
|
|
94
|
+
|
|
95
|
+
# --- Messaging ---
|
|
96
|
+
SLACK = "slack"
|
|
97
|
+
DISCORD = "discord"
|
|
98
|
+
TELEGRAM = "telegram"
|
|
99
|
+
WHATSAPP = "whatsapp"
|
|
100
|
+
|
|
101
|
+
# --- Productivity ---
|
|
102
|
+
NOTION = "notion"
|
|
103
|
+
AIRTABLE = "airtable"
|
|
104
|
+
ZAPIER = "zapier"
|
|
105
|
+
MAKE = "make"
|
|
106
|
+
|
|
107
|
+
# --- Monitoring ---
|
|
108
|
+
SENTRY = "sentry"
|
|
109
|
+
DATADOG = "datadog"
|
|
110
|
+
NEW_RELIC = "new_relic"
|
|
111
|
+
LOGFLARE = "logflare"
|
|
112
|
+
|
|
113
|
+
# --- Maps ---
|
|
114
|
+
GOOGLE_MAPS = "google_maps"
|
|
115
|
+
MAPBOX = "mapbox"
|
|
116
|
+
HERE = "here"
|
|
117
|
+
|
|
118
|
+
# --- Catch-all ---
|
|
119
|
+
CUSTOM = "custom"
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_string(cls, value: str) -> Provider:
|
|
123
|
+
"""Look up a provider by name (case-insensitive).
|
|
124
|
+
|
|
125
|
+
Attempts to match *value* against both the enum member **name**
|
|
126
|
+
(e.g. ``"OPENAI"``) and the enum member **value** (e.g. ``"openai"``).
|
|
127
|
+
Returns :attr:`Provider.CUSTOM` when no match is found.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
value: The provider string to look up.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The matching ``Provider`` member, or ``Provider.CUSTOM``.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> Provider.from_string("openai")
|
|
137
|
+
<Provider.OPENAI: 'openai'>
|
|
138
|
+
>>> Provider.from_string("ANTHROPIC")
|
|
139
|
+
<Provider.ANTHROPIC: 'anthropic'>
|
|
140
|
+
>>> Provider.from_string("unknown_service")
|
|
141
|
+
<Provider.CUSTOM: 'custom'>
|
|
142
|
+
"""
|
|
143
|
+
normalised = value.strip().upper()
|
|
144
|
+
|
|
145
|
+
# Fast path: match by member name (e.g. "OPENAI", "GOOGLE_GEMINI").
|
|
146
|
+
try:
|
|
147
|
+
return cls[normalised]
|
|
148
|
+
except KeyError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
# Slower path: match by member value (lowercase form).
|
|
152
|
+
lower = value.strip().lower()
|
|
153
|
+
for member in cls:
|
|
154
|
+
if member.value == lower:
|
|
155
|
+
return member
|
|
156
|
+
|
|
157
|
+
return cls.CUSTOM
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class KeyStatus(str, Enum):
|
|
161
|
+
"""Status of an API key in the key pool."""
|
|
162
|
+
|
|
163
|
+
ACTIVE = "active"
|
|
164
|
+
"""Key is available for use."""
|
|
165
|
+
|
|
166
|
+
INACTIVE = "inactive"
|
|
167
|
+
"""Key has been manually disabled."""
|
|
168
|
+
|
|
169
|
+
RATE_LIMITED = "rate_limited"
|
|
170
|
+
"""Key has hit a rate limit and is temporarily unavailable."""
|
|
171
|
+
|
|
172
|
+
EXPIRED = "expired"
|
|
173
|
+
"""Key has passed its expiration date."""
|
|
174
|
+
|
|
175
|
+
REVOKED = "revoked"
|
|
176
|
+
"""Key has been permanently revoked by the provider or admin."""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class StorageBackend(str, Enum):
|
|
180
|
+
"""Supported storage backends for persisting key data."""
|
|
181
|
+
|
|
182
|
+
MEMORY = "memory"
|
|
183
|
+
"""In-process dictionary — useful for testing and single-process apps."""
|
|
184
|
+
|
|
185
|
+
SQLITE = "sqlite"
|
|
186
|
+
"""SQLite file-based database."""
|
|
187
|
+
|
|
188
|
+
MONGODB = "mongodb"
|
|
189
|
+
"""MongoDB document store."""
|
|
190
|
+
|
|
191
|
+
POSTGRESQL = "postgresql"
|
|
192
|
+
"""PostgreSQL relational database."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class RotationStrategy(str, Enum):
|
|
196
|
+
"""Strategy for selecting the next API key from the pool."""
|
|
197
|
+
|
|
198
|
+
ROUND_ROBIN = "round_robin"
|
|
199
|
+
"""Cycle through keys in order."""
|
|
200
|
+
|
|
201
|
+
LEAST_USED = "least_used"
|
|
202
|
+
"""Pick the key with the fewest total uses."""
|
|
203
|
+
|
|
204
|
+
RANDOM = "random"
|
|
205
|
+
"""Pick a key at random."""
|
|
206
|
+
|
|
207
|
+
WEIGHTED = "weighted"
|
|
208
|
+
"""Pick a key based on assigned weights / priorities."""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Environment(str, Enum):
|
|
212
|
+
"""Application deployment environment."""
|
|
213
|
+
|
|
214
|
+
PRODUCTION = "production"
|
|
215
|
+
STAGING = "staging"
|
|
216
|
+
DEVELOPMENT = "development"
|
|
217
|
+
TESTING = "testing"
|