keycycle 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- {keycycle-0.3.2 → keycycle-0.4.0}/PKG-INFO +1 -1
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/adapters/generic_adapter.py +15 -15
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/adapters/openai_adapter.py +15 -15
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/constants.py +7 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/__init__.py +2 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/utils.py +23 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/key_rotation/rotating_mixin.py +11 -11
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/key_rotation/rotation_manager.py +63 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/PKG-INFO +1 -1
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/SOURCES.txt +2 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/pyproject.toml +1 -1
- keycycle-0.4.0/tests/test_dead_key_rotation.py +124 -0
- keycycle-0.4.0/tests/test_key_wait.py +78 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/MANIFEST.in +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/__init__.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/adapters/__init__.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/__init__.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/dataclasses.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/enums.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/loader.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/log_config.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/cerebras.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/cohere.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/gemini.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/groq.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/mistral.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/moonshot.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/openrouter.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models/openrouter_models.yaml +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/config/models.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/backoff.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/core/exceptions.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/key_rotation/__init__.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/legacy_multi_provider_wrapper.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/multi_client_wrapper.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/py.typed +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/usage/__init__.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/usage/db_logic.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/usage/usage_logger.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle/utils.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/dependency_links.txt +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/requires.txt +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/keycycle.egg-info/top_level.txt +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/setup.cfg +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_api_key_pinning.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_api_key_retrieval.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_cerebras_direct.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_custom_key_limits.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_multi_client_wrapper.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_payment_required_rotation.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_rate_limit_logic.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_specific_key_retrieval.py +0 -0
- {keycycle-0.3.2 → keycycle-0.4.0}/tests/test_streams_recording.py +0 -0
|
@@ -35,7 +35,7 @@ from ..config.constants import (
|
|
|
35
35
|
from ..core.utils import (
|
|
36
36
|
is_rate_limit_error,
|
|
37
37
|
is_temporary_rate_limit_error,
|
|
38
|
-
|
|
38
|
+
is_dead_key_error,
|
|
39
39
|
get_key_suffix,
|
|
40
40
|
)
|
|
41
41
|
from ..core.backoff import ExponentialBackoff, BackoffConfig
|
|
@@ -305,9 +305,9 @@ class SyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
|
305
305
|
limits = self.limit_resolver(model_id, None)
|
|
306
306
|
|
|
307
307
|
for attempt in range(self.config.max_retries + 1):
|
|
308
|
-
key_usage = self.manager.
|
|
308
|
+
key_usage = self.manager.acquire_key(model_id, limits, self.config.estimated_tokens)
|
|
309
309
|
if not key_usage:
|
|
310
|
-
raise RuntimeError(
|
|
310
|
+
raise RuntimeError(self.manager.unavailable_message(model_id))
|
|
311
311
|
|
|
312
312
|
# Create backoff for temporary rate limits
|
|
313
313
|
temp_backoff = ExponentialBackoff(BackoffConfig(
|
|
@@ -348,11 +348,11 @@ class SyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
|
348
348
|
time.sleep(delay)
|
|
349
349
|
continue # Retry with SAME key
|
|
350
350
|
|
|
351
|
-
#
|
|
351
|
+
# Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
|
|
352
352
|
# for this process, rotate to next key
|
|
353
|
-
if
|
|
353
|
+
if is_dead_key_error(e) and attempt < self.config.max_retries:
|
|
354
354
|
logger.warning(
|
|
355
|
-
"402/
|
|
355
|
+
"Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
|
|
356
356
|
model_id, get_key_suffix(key_usage.api_key),
|
|
357
357
|
attempt + 1, self.config.max_retries + 1
|
|
358
358
|
)
|
|
@@ -401,9 +401,9 @@ class SyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
|
401
401
|
final_tokens = chunk_tokens
|
|
402
402
|
yield chunk
|
|
403
403
|
except Exception as e:
|
|
404
|
-
if
|
|
404
|
+
if is_dead_key_error(e):
|
|
405
405
|
logger.warning(
|
|
406
|
-
"402/
|
|
406
|
+
"Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
|
|
407
407
|
model_id, get_key_suffix(key_usage.api_key)
|
|
408
408
|
)
|
|
409
409
|
key_usage.mark_dead()
|
|
@@ -446,9 +446,9 @@ class AsyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
|
446
446
|
limits = self.limit_resolver(model_id, None)
|
|
447
447
|
|
|
448
448
|
for attempt in range(self.config.max_retries + 1):
|
|
449
|
-
key_usage = self.manager.
|
|
449
|
+
key_usage = await self.manager.acquire_key_async(model_id, limits, self.config.estimated_tokens)
|
|
450
450
|
if not key_usage:
|
|
451
|
-
raise RuntimeError(
|
|
451
|
+
raise RuntimeError(self.manager.unavailable_message(model_id))
|
|
452
452
|
|
|
453
453
|
# Create backoff for temporary rate limits
|
|
454
454
|
temp_backoff = ExponentialBackoff(BackoffConfig(
|
|
@@ -487,11 +487,11 @@ class AsyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
|
487
487
|
await asyncio.sleep(delay)
|
|
488
488
|
continue # Retry with SAME key
|
|
489
489
|
|
|
490
|
-
#
|
|
490
|
+
# Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
|
|
491
491
|
# for this process, rotate to next key
|
|
492
|
-
if
|
|
492
|
+
if is_dead_key_error(e) and attempt < self.config.max_retries:
|
|
493
493
|
logger.warning(
|
|
494
|
-
"402/
|
|
494
|
+
"Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
|
|
495
495
|
model_id, get_key_suffix(key_usage.api_key),
|
|
496
496
|
attempt + 1, self.config.max_retries + 1
|
|
497
497
|
)
|
|
@@ -540,9 +540,9 @@ class AsyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
|
540
540
|
final_tokens = chunk_tokens
|
|
541
541
|
yield chunk
|
|
542
542
|
except Exception as e:
|
|
543
|
-
if
|
|
543
|
+
if is_dead_key_error(e):
|
|
544
544
|
logger.warning(
|
|
545
|
-
"402/
|
|
545
|
+
"Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
|
|
546
546
|
model_id, get_key_suffix(key_usage.api_key)
|
|
547
547
|
)
|
|
548
548
|
key_usage.mark_dead()
|
|
@@ -14,7 +14,7 @@ from ..config.constants import (
|
|
|
14
14
|
from ..core.utils import (
|
|
15
15
|
is_rate_limit_error,
|
|
16
16
|
is_temporary_rate_limit_error,
|
|
17
|
-
|
|
17
|
+
is_dead_key_error,
|
|
18
18
|
get_key_suffix,
|
|
19
19
|
)
|
|
20
20
|
from ..core.backoff import ExponentialBackoff, BackoffConfig
|
|
@@ -132,9 +132,9 @@ class RotatingOpenAIClient(BaseRotatingClient):
|
|
|
132
132
|
kwargs['stream_options'] = {"include_usage": True}
|
|
133
133
|
|
|
134
134
|
for attempt in range(self.max_retries + 1):
|
|
135
|
-
key_usage = self.manager.
|
|
135
|
+
key_usage = self.manager.acquire_key(model_id, limits, self.estimated_tokens)
|
|
136
136
|
if not key_usage:
|
|
137
|
-
raise RuntimeError(
|
|
137
|
+
raise RuntimeError(self.manager.unavailable_message(model_id))
|
|
138
138
|
|
|
139
139
|
# Create backoff for temporary rate limits
|
|
140
140
|
temp_backoff = ExponentialBackoff(BackoffConfig(
|
|
@@ -171,11 +171,11 @@ class RotatingOpenAIClient(BaseRotatingClient):
|
|
|
171
171
|
time.sleep(delay)
|
|
172
172
|
continue # Retry with SAME key
|
|
173
173
|
|
|
174
|
-
#
|
|
174
|
+
# Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
|
|
175
175
|
# for this process, rotate to next key
|
|
176
|
-
if
|
|
176
|
+
if is_dead_key_error(e) and attempt < self.max_retries:
|
|
177
177
|
logger.warning(
|
|
178
|
-
"402/
|
|
178
|
+
"Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
|
|
179
179
|
model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
|
|
180
180
|
)
|
|
181
181
|
key_usage.mark_dead()
|
|
@@ -217,9 +217,9 @@ class RotatingOpenAIClient(BaseRotatingClient):
|
|
|
217
217
|
final_tokens = chunk.usage.total_tokens
|
|
218
218
|
yield chunk
|
|
219
219
|
except Exception as e:
|
|
220
|
-
if
|
|
220
|
+
if is_dead_key_error(e):
|
|
221
221
|
logger.warning(
|
|
222
|
-
"402/
|
|
222
|
+
"Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
|
|
223
223
|
model_id, get_key_suffix(key_usage.api_key)
|
|
224
224
|
)
|
|
225
225
|
key_usage.mark_dead()
|
|
@@ -266,9 +266,9 @@ class RotatingAsyncOpenAIClient(BaseRotatingClient):
|
|
|
266
266
|
kwargs['stream_options'] = {"include_usage": True}
|
|
267
267
|
|
|
268
268
|
for attempt in range(self.max_retries + 1):
|
|
269
|
-
key_usage = self.manager.
|
|
269
|
+
key_usage = await self.manager.acquire_key_async(model_id, limits, self.estimated_tokens)
|
|
270
270
|
if not key_usage:
|
|
271
|
-
raise RuntimeError(
|
|
271
|
+
raise RuntimeError(self.manager.unavailable_message(model_id))
|
|
272
272
|
|
|
273
273
|
# Create backoff for temporary rate limits
|
|
274
274
|
temp_backoff = ExponentialBackoff(BackoffConfig(
|
|
@@ -305,11 +305,11 @@ class RotatingAsyncOpenAIClient(BaseRotatingClient):
|
|
|
305
305
|
await asyncio.sleep(delay)
|
|
306
306
|
continue # Retry with SAME key
|
|
307
307
|
|
|
308
|
-
#
|
|
308
|
+
# Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
|
|
309
309
|
# for this process, rotate to next key
|
|
310
|
-
if
|
|
310
|
+
if is_dead_key_error(e) and attempt < self.max_retries:
|
|
311
311
|
logger.warning(
|
|
312
|
-
"402/
|
|
312
|
+
"Dead key (401/402/403) hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
|
|
313
313
|
model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
|
|
314
314
|
)
|
|
315
315
|
key_usage.mark_dead()
|
|
@@ -351,9 +351,9 @@ class RotatingAsyncOpenAIClient(BaseRotatingClient):
|
|
|
351
351
|
final_tokens = chunk.usage.total_tokens
|
|
352
352
|
yield chunk
|
|
353
353
|
except Exception as e:
|
|
354
|
-
if
|
|
354
|
+
if is_dead_key_error(e):
|
|
355
355
|
logger.warning(
|
|
356
|
-
"402/
|
|
356
|
+
"Dead key (401/402/403) hit during streaming for %s on key ...%s. Marking key dead.",
|
|
357
357
|
model_id, get_key_suffix(key_usage.api_key)
|
|
358
358
|
)
|
|
359
359
|
key_usage.mark_dead()
|
|
@@ -31,5 +31,12 @@ TEMP_RATE_LIMIT_MULTIPLIER = 2.0
|
|
|
31
31
|
# Key rotation delay (seconds to wait after rotating to a new key)
|
|
32
32
|
KEY_ROTATION_DELAY_SECONDS = 0.5
|
|
33
33
|
|
|
34
|
+
# How long acquire_key() polls for a live key to leave its rate window /
|
|
35
|
+
# cooldown before giving up. A single production key at its per-minute cap
|
|
36
|
+
# is the common case (Cohere, 2026-09-07): the right answer is to wait for
|
|
37
|
+
# the window, not to abort the caller's whole run.
|
|
38
|
+
KEY_WAIT_MAX_SECONDS = 120.0
|
|
39
|
+
KEY_WAIT_POLL_SECONDS = 1.0
|
|
40
|
+
|
|
34
41
|
# Usage logging batch size
|
|
35
42
|
USAGE_LOG_BATCH_SIZE = 50
|
|
@@ -14,6 +14,7 @@ from .utils import (
|
|
|
14
14
|
get_key_suffix,
|
|
15
15
|
is_rate_limit_error,
|
|
16
16
|
is_auth_error,
|
|
17
|
+
is_dead_key_error,
|
|
17
18
|
validate_api_key,
|
|
18
19
|
)
|
|
19
20
|
from .backoff import ExponentialBackoff, BackoffConfig
|
|
@@ -33,6 +34,7 @@ __all__ = [
|
|
|
33
34
|
"get_key_suffix",
|
|
34
35
|
"is_rate_limit_error",
|
|
35
36
|
"is_auth_error",
|
|
37
|
+
"is_dead_key_error",
|
|
36
38
|
"validate_api_key",
|
|
37
39
|
# Backoff
|
|
38
40
|
"ExponentialBackoff",
|
|
@@ -241,6 +241,29 @@ def is_auth_error(e: Exception) -> bool:
|
|
|
241
241
|
return False
|
|
242
242
|
|
|
243
243
|
|
|
244
|
+
# A key that answers 401/402/403 is unusable for good (revoked, unpaid,
|
|
245
|
+
# or its project denied) — no cooldown will fix it, so rotate off it.
|
|
246
|
+
DEAD_KEY_STATUS_CODES = AUTH_STATUS_CODES | PAYMENT_REQUIRED_STATUS_CODES
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def is_dead_key_error(e: Exception) -> bool:
|
|
250
|
+
"""
|
|
251
|
+
Detect errors that mean THIS KEY is dead, not merely busy: HTTP 401/403
|
|
252
|
+
(invalid, revoked, or project-denied key — e.g. Gemini's
|
|
253
|
+
"Your project has been denied access") and 402 (credits exhausted).
|
|
254
|
+
|
|
255
|
+
A status_code, when present, is authoritative: 429s are never dead keys
|
|
256
|
+
whatever their message says. Without one, fall back to the string
|
|
257
|
+
heuristics of is_payment_required_error / is_auth_error.
|
|
258
|
+
"""
|
|
259
|
+
status = getattr(e, "status_code", None)
|
|
260
|
+
if status in DEAD_KEY_STATUS_CODES:
|
|
261
|
+
return True
|
|
262
|
+
if status == 429:
|
|
263
|
+
return False
|
|
264
|
+
return is_payment_required_error(e) or is_auth_error(e)
|
|
265
|
+
|
|
266
|
+
|
|
244
267
|
def validate_api_key(api_key: str) -> bool:
|
|
245
268
|
"""
|
|
246
269
|
Basic validation of API key format.
|
|
@@ -15,7 +15,7 @@ from ..config.constants import (
|
|
|
15
15
|
from ..core.utils import (
|
|
16
16
|
is_rate_limit_error,
|
|
17
17
|
is_temporary_rate_limit_error,
|
|
18
|
-
|
|
18
|
+
is_dead_key_error,
|
|
19
19
|
get_key_suffix,
|
|
20
20
|
)
|
|
21
21
|
from ..core.backoff import ExponentialBackoff, BackoffConfig
|
|
@@ -171,11 +171,11 @@ class RotatingCredentialsMixin:
|
|
|
171
171
|
time.sleep(delay)
|
|
172
172
|
continue # Retry with SAME key
|
|
173
173
|
|
|
174
|
-
#
|
|
174
|
+
# Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
|
|
175
175
|
# for this process, rotate to next key
|
|
176
|
-
if
|
|
176
|
+
if is_dead_key_error(e) and attempt < limit:
|
|
177
177
|
self.logger.warning(
|
|
178
|
-
"402
|
|
178
|
+
"Dead key (401/402/403) on key %s (Sync) [%s]. Marking key dead and rotating (%d/%d).",
|
|
179
179
|
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
180
180
|
)
|
|
181
181
|
key_usage.mark_dead()
|
|
@@ -228,11 +228,11 @@ class RotatingCredentialsMixin:
|
|
|
228
228
|
await asyncio.sleep(delay)
|
|
229
229
|
continue # Retry with SAME key
|
|
230
230
|
|
|
231
|
-
#
|
|
231
|
+
# Dead key (401/403 revoked/denied, 402 credits exhausted) - key is dead
|
|
232
232
|
# for this process, rotate to next key
|
|
233
|
-
if
|
|
233
|
+
if is_dead_key_error(e) and attempt < limit:
|
|
234
234
|
self.logger.warning(
|
|
235
|
-
"402
|
|
235
|
+
"Dead key (401/402/403) on key %s (Async) [%s]. Marking key dead and rotating (%d/%d).",
|
|
236
236
|
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
237
237
|
)
|
|
238
238
|
key_usage.mark_dead()
|
|
@@ -300,9 +300,9 @@ class RotatingCredentialsMixin:
|
|
|
300
300
|
time.sleep(delay)
|
|
301
301
|
continue
|
|
302
302
|
|
|
303
|
-
if
|
|
303
|
+
if is_dead_key_error(e) and attempt < limit:
|
|
304
304
|
self.logger.warning(
|
|
305
|
-
"402
|
|
305
|
+
"Dead key (401/402/403) on key %s (Sync Stream) [%s]. Marking key dead and rotating (%d/%d).",
|
|
306
306
|
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
307
307
|
)
|
|
308
308
|
key_usage.mark_dead()
|
|
@@ -366,9 +366,9 @@ class RotatingCredentialsMixin:
|
|
|
366
366
|
await asyncio.sleep(delay)
|
|
367
367
|
continue
|
|
368
368
|
|
|
369
|
-
if
|
|
369
|
+
if is_dead_key_error(e) and attempt < limit:
|
|
370
370
|
self.logger.warning(
|
|
371
|
-
"402
|
|
371
|
+
"Dead key (401/402/403) on key %s (Async Stream) [%s]. Marking key dead and rotating (%d/%d).",
|
|
372
372
|
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
373
373
|
)
|
|
374
374
|
key_usage.mark_dead()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
+
import asyncio
|
|
2
3
|
import atexit
|
|
3
4
|
import threading
|
|
4
5
|
from threading import Lock, Event
|
|
@@ -17,6 +18,8 @@ from ..config.constants import (
|
|
|
17
18
|
CLEANUP_INTERVAL_SECONDS,
|
|
18
19
|
HISTORY_LOOKBACK_SECONDS,
|
|
19
20
|
DEFAULT_COOLDOWN_SECONDS,
|
|
21
|
+
KEY_WAIT_MAX_SECONDS,
|
|
22
|
+
KEY_WAIT_POLL_SECONDS,
|
|
20
23
|
)
|
|
21
24
|
from ..core.utils import get_key_suffix, KeyEntry, normalize_key_entries
|
|
22
25
|
from ..usage.usage_logger import AsyncUsageLogger
|
|
@@ -35,8 +38,10 @@ class RotatingKeyManager:
|
|
|
35
38
|
cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS,
|
|
36
39
|
limit_resolver: Optional[Callable[[str, Optional[str]], RateLimits]] = None,
|
|
37
40
|
api_key_param: str = "api_key",
|
|
41
|
+
max_key_wait_seconds: float = KEY_WAIT_MAX_SECONDS,
|
|
38
42
|
):
|
|
39
43
|
self.provider_name = provider_name
|
|
44
|
+
self.max_key_wait_seconds = max_key_wait_seconds
|
|
40
45
|
self.logger = logger or default_logger
|
|
41
46
|
self.strategy = strategy
|
|
42
47
|
self.cooldown_seconds = cooldown_seconds
|
|
@@ -147,6 +152,64 @@ class RotatingKeyManager:
|
|
|
147
152
|
return key
|
|
148
153
|
return None
|
|
149
154
|
|
|
155
|
+
def all_keys_dead(self) -> bool:
|
|
156
|
+
"""True when every key has been marked dead (401/402/403) - nothing to wait for."""
|
|
157
|
+
return bool(self.keys) and all(k.dead for k in self.keys)
|
|
158
|
+
|
|
159
|
+
def unavailable_message(self, model_id: str) -> str:
|
|
160
|
+
dead = sum(1 for k in self.keys if k.dead)
|
|
161
|
+
if self.all_keys_dead():
|
|
162
|
+
return f"No usable keys for {model_id}: all {len(self.keys)} keys are dead (401/402/403)"
|
|
163
|
+
return (
|
|
164
|
+
f"No available keys for {model_id}: {dead}/{len(self.keys)} dead, the rest stayed "
|
|
165
|
+
f"rate-limited or cooling down for {self.max_key_wait_seconds:.0f}s"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def acquire_key(
|
|
169
|
+
self,
|
|
170
|
+
model_id: str,
|
|
171
|
+
default_limits: RateLimits,
|
|
172
|
+
estimated_tokens: int = 1000,
|
|
173
|
+
max_wait_seconds: Optional[float] = None,
|
|
174
|
+
poll_seconds: float = KEY_WAIT_POLL_SECONDS,
|
|
175
|
+
) -> Optional[KeyUsage]:
|
|
176
|
+
"""
|
|
177
|
+
get_key, but WAIT while every live key is inside a rate window or a
|
|
178
|
+
cooldown instead of handing back None at once.
|
|
179
|
+
|
|
180
|
+
Returns None only when every key is dead or max_wait elapses - so a
|
|
181
|
+
caller with one production key at its per-minute cap rides the window
|
|
182
|
+
out rather than aborting a long run.
|
|
183
|
+
"""
|
|
184
|
+
max_wait = self.max_key_wait_seconds if max_wait_seconds is None else max_wait_seconds
|
|
185
|
+
deadline = time.monotonic() + max_wait
|
|
186
|
+
while True:
|
|
187
|
+
key = self.get_key(model_id, default_limits, estimated_tokens)
|
|
188
|
+
if key is not None:
|
|
189
|
+
return key
|
|
190
|
+
if self.all_keys_dead() or time.monotonic() >= deadline:
|
|
191
|
+
return None
|
|
192
|
+
time.sleep(poll_seconds)
|
|
193
|
+
|
|
194
|
+
async def acquire_key_async(
|
|
195
|
+
self,
|
|
196
|
+
model_id: str,
|
|
197
|
+
default_limits: RateLimits,
|
|
198
|
+
estimated_tokens: int = 1000,
|
|
199
|
+
max_wait_seconds: Optional[float] = None,
|
|
200
|
+
poll_seconds: float = KEY_WAIT_POLL_SECONDS,
|
|
201
|
+
) -> Optional[KeyUsage]:
|
|
202
|
+
"""acquire_key for async callers (non-blocking sleep)."""
|
|
203
|
+
max_wait = self.max_key_wait_seconds if max_wait_seconds is None else max_wait_seconds
|
|
204
|
+
deadline = time.monotonic() + max_wait
|
|
205
|
+
while True:
|
|
206
|
+
key = self.get_key(model_id, default_limits, estimated_tokens)
|
|
207
|
+
if key is not None:
|
|
208
|
+
return key
|
|
209
|
+
if self.all_keys_dead() or time.monotonic() >= deadline:
|
|
210
|
+
return None
|
|
211
|
+
await asyncio.sleep(poll_seconds)
|
|
212
|
+
|
|
150
213
|
def get_specific_key(self, identifier: Union[int, str], model_id: str, estimated_tokens: int = 1000) -> Optional[KeyUsage]:
|
|
151
214
|
"""
|
|
152
215
|
Get a specific key by index or identifier.
|
|
@@ -42,6 +42,8 @@ tests/test_api_key_pinning.py
|
|
|
42
42
|
tests/test_api_key_retrieval.py
|
|
43
43
|
tests/test_cerebras_direct.py
|
|
44
44
|
tests/test_custom_key_limits.py
|
|
45
|
+
tests/test_dead_key_rotation.py
|
|
46
|
+
tests/test_key_wait.py
|
|
45
47
|
tests/test_multi_client_wrapper.py
|
|
46
48
|
tests/test_payment_required_rotation.py
|
|
47
49
|
tests/test_rate_limit_logic.py
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for dead-key (HTTP 401/402/403) rotation.
|
|
3
|
+
|
|
4
|
+
Extends test_payment_required_rotation.py: 401/403 mean the key itself is
|
|
5
|
+
unusable (revoked, invalid, or its project denied — Gemini answers
|
|
6
|
+
"Your project has been denied access" with a 403), exactly like a 402.
|
|
7
|
+
Such a key must be marked dead and rotated off, never allowed to abort a
|
|
8
|
+
whole run while dozens of healthy keys sit idle (emails_gen pilot,
|
|
9
|
+
2026-09-07).
|
|
10
|
+
"""
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from keycycle.core.utils import is_dead_key_error
|
|
20
|
+
from keycycle.config.dataclasses import KeyUsage, RateLimits
|
|
21
|
+
from keycycle.config.enums import RateLimitStrategy
|
|
22
|
+
from keycycle.key_rotation.rotation_manager import RotatingKeyManager
|
|
23
|
+
from keycycle.adapters.openai_adapter import RotatingOpenAIClient
|
|
24
|
+
except ImportError:
|
|
25
|
+
from keycycle.keycycle.core.utils import is_dead_key_error
|
|
26
|
+
from keycycle.keycycle.config.dataclasses import KeyUsage, RateLimits
|
|
27
|
+
from keycycle.keycycle.config.enums import RateLimitStrategy
|
|
28
|
+
from keycycle.keycycle.key_rotation.rotation_manager import RotatingKeyManager
|
|
29
|
+
from keycycle.keycycle.adapters.openai_adapter import RotatingOpenAIClient
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MockAPIError(Exception):
|
|
33
|
+
def __init__(self, message, status_code=None, body=None):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.body = body
|
|
37
|
+
self.response = body
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestDeadKeyDetection(unittest.TestCase):
|
|
41
|
+
def test_403_project_denied_is_dead(self):
|
|
42
|
+
e = MockAPIError(
|
|
43
|
+
"Error code: 403 - [{'error': {'code': 403, 'message': 'Your project has "
|
|
44
|
+
"been denied access. Please contact support.', 'status': 'PERMISSION_DENIED'}}]",
|
|
45
|
+
status_code=403,
|
|
46
|
+
)
|
|
47
|
+
self.assertTrue(is_dead_key_error(e))
|
|
48
|
+
|
|
49
|
+
def test_401_invalid_key_is_dead(self):
|
|
50
|
+
self.assertTrue(is_dead_key_error(MockAPIError("Unauthorized", status_code=401)))
|
|
51
|
+
|
|
52
|
+
def test_402_still_dead(self):
|
|
53
|
+
self.assertTrue(is_dead_key_error(MockAPIError("Payment Required", status_code=402)))
|
|
54
|
+
|
|
55
|
+
def test_429_never_dead_even_with_scary_message(self):
|
|
56
|
+
# A rate limit whose body mentions "forbidden" is still just a rate limit.
|
|
57
|
+
e = MockAPIError("429 rate limit: forbidden to exceed quota", status_code=429)
|
|
58
|
+
self.assertFalse(is_dead_key_error(e))
|
|
59
|
+
|
|
60
|
+
def test_string_fallback_without_status_code(self):
|
|
61
|
+
self.assertTrue(is_dead_key_error(Exception("Error code: 403 - PERMISSION_DENIED")))
|
|
62
|
+
self.assertFalse(is_dead_key_error(Exception("Something unrelated exploded")))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestDeadKeyRotation(unittest.TestCase):
|
|
66
|
+
"""A 403 on key A must mark A dead and let the request succeed on key B
|
|
67
|
+
(same fixture shape as TestOpenAIAdapter402Rotation)."""
|
|
68
|
+
|
|
69
|
+
def setUp(self):
|
|
70
|
+
self.api_keys = ["sk-key-one-AAAAAAAA", "sk-key-two-BBBBBBBB"]
|
|
71
|
+
mock_db = MagicMock()
|
|
72
|
+
mock_db.load_provider_history.return_value = []
|
|
73
|
+
self.manager = RotatingKeyManager(
|
|
74
|
+
api_keys=self.api_keys,
|
|
75
|
+
provider_name="test",
|
|
76
|
+
strategy=RateLimitStrategy.PER_MODEL,
|
|
77
|
+
db=mock_db,
|
|
78
|
+
)
|
|
79
|
+
limits = RateLimits(100, 6000, 10000, 1000000)
|
|
80
|
+
self.client = RotatingOpenAIClient(
|
|
81
|
+
manager=self.manager,
|
|
82
|
+
limit_resolver=lambda model_id, key_suffix: limits,
|
|
83
|
+
default_model="test-model",
|
|
84
|
+
max_retries=5,
|
|
85
|
+
provider="gemini",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _factory(self, denied_suffixes):
|
|
89
|
+
def factory(api_key):
|
|
90
|
+
fake = MagicMock()
|
|
91
|
+
if api_key[-8:] in denied_suffixes:
|
|
92
|
+
fake.chat.completions.create.side_effect = MockAPIError(
|
|
93
|
+
"Your project has been denied access. Please contact support.",
|
|
94
|
+
status_code=403,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
response = MagicMock()
|
|
98
|
+
response.usage.total_tokens = 7
|
|
99
|
+
fake.chat.completions.create.return_value = response
|
|
100
|
+
return fake
|
|
101
|
+
return factory
|
|
102
|
+
|
|
103
|
+
def test_403_marks_key_dead_and_rotates(self):
|
|
104
|
+
with patch.object(
|
|
105
|
+
self.client, "_get_fresh_client", side_effect=self._factory({"AAAAAAAA"})
|
|
106
|
+
), patch("keycycle.adapters.openai_adapter.time.sleep"):
|
|
107
|
+
result = self.client.chat.completions.create(model="test-model")
|
|
108
|
+
|
|
109
|
+
self.assertEqual(result.usage.total_tokens, 7)
|
|
110
|
+
dead = {k.api_key[-8:] for k in self.manager.keys if k.dead}
|
|
111
|
+
self.assertEqual(dead, {"AAAAAAAA"})
|
|
112
|
+
|
|
113
|
+
def test_all_403_raises_without_hanging(self):
|
|
114
|
+
with patch.object(
|
|
115
|
+
self.client, "_get_fresh_client",
|
|
116
|
+
side_effect=self._factory({"AAAAAAAA", "BBBBBBBB"}),
|
|
117
|
+
), patch("keycycle.adapters.openai_adapter.time.sleep"):
|
|
118
|
+
with self.assertRaises(Exception):
|
|
119
|
+
self.client.chat.completions.create(model="test-model")
|
|
120
|
+
self.assertTrue(all(k.dead for k in self.manager.keys))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
acquire_key: wait for a rate window / cooldown instead of failing at once.
|
|
3
|
+
|
|
4
|
+
One production key at its per-minute cap (Cohere, emails_gen pilot
|
|
5
|
+
2026-09-07) used to surface as "No available keys" after the first
|
|
6
|
+
window filled, aborting a 2,000-document run 20 calls in.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import MagicMock
|
|
11
|
+
import sys
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from keycycle.config.dataclasses import RateLimits
|
|
18
|
+
from keycycle.config.enums import RateLimitStrategy
|
|
19
|
+
from keycycle.key_rotation.rotation_manager import RotatingKeyManager
|
|
20
|
+
except ImportError:
|
|
21
|
+
from keycycle.keycycle.config.dataclasses import RateLimits
|
|
22
|
+
from keycycle.keycycle.config.enums import RateLimitStrategy
|
|
23
|
+
from keycycle.keycycle.key_rotation.rotation_manager import RotatingKeyManager
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _manager(keys, cooldown_seconds=1):
|
|
27
|
+
db = MagicMock()
|
|
28
|
+
db.load_provider_history.return_value = []
|
|
29
|
+
return RotatingKeyManager(
|
|
30
|
+
api_keys=keys,
|
|
31
|
+
provider_name="test",
|
|
32
|
+
strategy=RateLimitStrategy.PER_MODEL,
|
|
33
|
+
db=db,
|
|
34
|
+
cooldown_seconds=cooldown_seconds,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestAcquireKey(unittest.TestCase):
|
|
39
|
+
def setUp(self):
|
|
40
|
+
self.limits = RateLimits(100, 6000, 10000, 1000000)
|
|
41
|
+
|
|
42
|
+
def test_returns_immediately_when_a_key_is_free(self):
|
|
43
|
+
manager = _manager(["sk-only-key-AAAAAAAA"])
|
|
44
|
+
started = time.monotonic()
|
|
45
|
+
key = manager.acquire_key("m", self.limits, estimated_tokens=10)
|
|
46
|
+
self.assertIsNotNone(key)
|
|
47
|
+
self.assertLess(time.monotonic() - started, 0.5)
|
|
48
|
+
|
|
49
|
+
def test_waits_out_a_cooldown_then_returns_the_key(self):
|
|
50
|
+
manager = _manager(["sk-only-key-AAAAAAAA"], cooldown_seconds=1)
|
|
51
|
+
manager.keys[0].trigger_cooldown()
|
|
52
|
+
self.assertIsNone(manager.get_key("m", self.limits, 10)) # the old behavior
|
|
53
|
+
|
|
54
|
+
started = time.monotonic()
|
|
55
|
+
key = manager.acquire_key("m", self.limits, 10, max_wait_seconds=5, poll_seconds=0.1)
|
|
56
|
+
self.assertIsNotNone(key)
|
|
57
|
+
self.assertGreaterEqual(time.monotonic() - started, 0.5)
|
|
58
|
+
|
|
59
|
+
def test_all_dead_returns_none_without_waiting(self):
|
|
60
|
+
manager = _manager(["sk-a-AAAAAAAA", "sk-b-BBBBBBBB"])
|
|
61
|
+
for k in manager.keys:
|
|
62
|
+
k.mark_dead()
|
|
63
|
+
started = time.monotonic()
|
|
64
|
+
self.assertIsNone(manager.acquire_key("m", self.limits, 10, max_wait_seconds=5))
|
|
65
|
+
self.assertLess(time.monotonic() - started, 0.5)
|
|
66
|
+
self.assertIn("all 2 keys are dead", manager.unavailable_message("m"))
|
|
67
|
+
|
|
68
|
+
def test_gives_up_after_max_wait(self):
|
|
69
|
+
manager = _manager(["sk-only-key-AAAAAAAA"], cooldown_seconds=60)
|
|
70
|
+
manager.keys[0].trigger_cooldown()
|
|
71
|
+
started = time.monotonic()
|
|
72
|
+
self.assertIsNone(manager.acquire_key("m", self.limits, 10, max_wait_seconds=0.3, poll_seconds=0.1))
|
|
73
|
+
self.assertLess(time.monotonic() - started, 2)
|
|
74
|
+
self.assertIn("rate-limited or cooling down", manager.unavailable_message("m"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|