keycycle 0.3.0__tar.gz → 0.3.2__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.0 → keycycle-0.3.2}/PKG-INFO +1 -1
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/adapters/openai_adapter.py +1 -0
- keycycle-0.3.2/keycycle/config/models/mistral.yaml +13 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models.py +5 -2
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/utils.py +45 -12
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/PKG-INFO +1 -1
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/SOURCES.txt +1 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/pyproject.toml +1 -1
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_multi_client_wrapper.py +47 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/MANIFEST.in +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/__init__.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/adapters/__init__.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/adapters/generic_adapter.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/__init__.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/constants.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/dataclasses.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/enums.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/loader.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/log_config.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/cerebras.yaml +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/cohere.yaml +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/gemini.yaml +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/groq.yaml +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/moonshot.yaml +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/openrouter.yaml +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/openrouter_models.yaml +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/__init__.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/backoff.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/exceptions.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/key_rotation/__init__.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/key_rotation/rotating_mixin.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/key_rotation/rotation_manager.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/legacy_multi_provider_wrapper.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/multi_client_wrapper.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/py.typed +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/usage/__init__.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/usage/db_logic.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/usage/usage_logger.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/utils.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/dependency_links.txt +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/requires.txt +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/top_level.txt +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/setup.cfg +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_api_key_pinning.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_api_key_retrieval.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_cerebras_direct.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_custom_key_limits.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_payment_required_rotation.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_rate_limit_logic.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_specific_key_retrieval.py +0 -0
- {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_streams_recording.py +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Mistral API rate limits (dummy high limits, same posture as moonshot.yaml)
|
|
2
|
+
# Base URL: https://api.mistral.ai/v1 (OpenAI-compatible chat completions)
|
|
3
|
+
# Not independently researched against Mistral's published tier limits yet --
|
|
4
|
+
# a single key (NUM_MISTRAL=1, no rotation benefit) makes local throttling
|
|
5
|
+
# low-value for now; Mistral's own 429s still surface and rotate normally.
|
|
6
|
+
|
|
7
|
+
default:
|
|
8
|
+
requests_per_minute: 10000
|
|
9
|
+
requests_per_hour: 100000
|
|
10
|
+
requests_per_day: 1000000
|
|
11
|
+
tokens_per_minute: 10000000
|
|
12
|
+
tokens_per_hour: 100000000
|
|
13
|
+
tokens_per_day: 1000000000
|
|
@@ -16,6 +16,7 @@ class ModelDict(TypedDict):
|
|
|
16
16
|
openrouter: Any
|
|
17
17
|
cohere: Any
|
|
18
18
|
moonshot: Any
|
|
19
|
+
mistral: Any
|
|
19
20
|
|
|
20
21
|
PROVIDER_STRATEGIES: ModelDict = {
|
|
21
22
|
'cerebras': RateLimitStrategy.PER_MODEL,
|
|
@@ -23,7 +24,8 @@ PROVIDER_STRATEGIES: ModelDict = {
|
|
|
23
24
|
'gemini': RateLimitStrategy.PER_MODEL,
|
|
24
25
|
'openrouter': RateLimitStrategy.GLOBAL,
|
|
25
26
|
'cohere': RateLimitStrategy.PER_MODEL,
|
|
26
|
-
'moonshot': RateLimitStrategy.PER_MODEL
|
|
27
|
+
'moonshot': RateLimitStrategy.PER_MODEL,
|
|
28
|
+
'mistral': RateLimitStrategy.PER_MODEL
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
# Load Cohere tiers first to handle the env var logic
|
|
@@ -44,7 +46,8 @@ MODEL_LIMITS: ModelDict = {
|
|
|
44
46
|
# Same for every model
|
|
45
47
|
'default': COHERE_TIERS.get(os.getenv('COHERE_TIER', 'free').lower(), COHERE_TIERS['free'])
|
|
46
48
|
},
|
|
47
|
-
'moonshot': load_rate_limits_from_yaml(os.path.join(MODELS_DIR, 'moonshot.yaml'))
|
|
49
|
+
'moonshot': load_rate_limits_from_yaml(os.path.join(MODELS_DIR, 'moonshot.yaml')),
|
|
50
|
+
'mistral': load_rate_limits_from_yaml(os.path.join(MODELS_DIR, 'mistral.yaml'))
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
OPENROUTER_MODELS = load_openrouter_models(os.path.join(MODELS_DIR, 'openrouter_models.yaml'))
|
|
@@ -313,26 +313,59 @@ def load_api_keys(
|
|
|
313
313
|
env_path = Path.cwd() / ".env"
|
|
314
314
|
load_dotenv(dotenv_path=env_path, override=True)
|
|
315
315
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
provider_upper = provider.upper()
|
|
317
|
+
num_keys_var = f"NUM_{provider_upper}"
|
|
318
|
+
num_keys_raw = os.getenv(num_keys_var)
|
|
319
|
+
if not num_keys_raw:
|
|
319
320
|
raise ValueError(f"Environment variable '{num_keys_var}' not found.")
|
|
320
321
|
try:
|
|
321
|
-
|
|
322
|
+
expected_count = int(num_keys_raw)
|
|
322
323
|
except ValueError:
|
|
323
|
-
raise ValueError(f"'{num_keys_var}' must be an integer, got: {
|
|
324
|
+
raise ValueError(f"'{num_keys_var}' must be an integer, got: {num_keys_raw}")
|
|
325
|
+
|
|
326
|
+
# NUM_{PROVIDER} is AUTHORITATIVE (0.3.2, reverting 0.3.1's scan-all):
|
|
327
|
+
# the fleet convention is that the first NUM indices (sorted ascending)
|
|
328
|
+
# are the sanctioned live keys, and keys parked at higher indices beyond
|
|
329
|
+
# a numbering gap are dead or spare on purpose -- loading them would put
|
|
330
|
+
# known-bad keys back into rotation. Gaps INSIDE the sanctioned range
|
|
331
|
+
# are tolerated (a retired key's index can be missing); the loader takes
|
|
332
|
+
# the first NUM indices that actually exist and warns if it comes up
|
|
333
|
+
# short, but never loads past NUM keys.
|
|
334
|
+
key_pattern = re.compile(rf"^{re.escape(provider_upper)}_API_KEY_(\d+)$")
|
|
335
|
+
found: Dict[int, str] = {}
|
|
336
|
+
for env_name, value in os.environ.items():
|
|
337
|
+
match = key_pattern.match(env_name)
|
|
338
|
+
if match and value:
|
|
339
|
+
found[int(match.group(1))] = value
|
|
340
|
+
|
|
341
|
+
if not found:
|
|
342
|
+
raise ValueError(f"Missing API key: {provider_upper}_API_KEY_1")
|
|
343
|
+
|
|
344
|
+
all_indices = sorted(found)
|
|
345
|
+
indices = all_indices[:expected_count]
|
|
346
|
+
excluded = all_indices[expected_count:]
|
|
347
|
+
if excluded:
|
|
348
|
+
logging.getLogger(__name__).info(
|
|
349
|
+
"%s: %s=%d -- using the first %d key indices %s; excluding %d "
|
|
350
|
+
"parked key(s) at indices %s (dead/spare by convention).",
|
|
351
|
+
provider_upper, num_keys_var, expected_count, len(indices), indices,
|
|
352
|
+
len(excluded), excluded,
|
|
353
|
+
)
|
|
354
|
+
if len(indices) < expected_count:
|
|
355
|
+
logging.getLogger(__name__).warning(
|
|
356
|
+
"%s: only %d API key(s) found (indices %s) but %s=%d -- the "
|
|
357
|
+
"declared count is stale. Using the %d key(s) actually found.",
|
|
358
|
+
provider_upper, len(indices), indices, num_keys_var, expected_count,
|
|
359
|
+
len(indices),
|
|
360
|
+
)
|
|
324
361
|
|
|
325
362
|
keys: List[KeyEntry] = []
|
|
326
|
-
for i in
|
|
327
|
-
|
|
328
|
-
key = os.getenv(key_var)
|
|
329
|
-
if not key:
|
|
330
|
-
raise ValueError(f"Missing API key: {key_var}")
|
|
331
|
-
|
|
363
|
+
for i in indices:
|
|
364
|
+
key = found[i]
|
|
332
365
|
if extra_params:
|
|
333
366
|
key_dict: Dict[str, Any] = {api_key_param: key}
|
|
334
367
|
for param in extra_params:
|
|
335
|
-
val = os.getenv(f"{
|
|
368
|
+
val = os.getenv(f"{provider_upper}_{param.upper()}_{i}")
|
|
336
369
|
if val:
|
|
337
370
|
key_dict[param] = val
|
|
338
371
|
keys.append(key_dict)
|
|
@@ -24,6 +24,7 @@ keycycle/config/models/cerebras.yaml
|
|
|
24
24
|
keycycle/config/models/cohere.yaml
|
|
25
25
|
keycycle/config/models/gemini.yaml
|
|
26
26
|
keycycle/config/models/groq.yaml
|
|
27
|
+
keycycle/config/models/mistral.yaml
|
|
27
28
|
keycycle/config/models/moonshot.yaml
|
|
28
29
|
keycycle/config/models/openrouter.yaml
|
|
29
30
|
keycycle/config/models/openrouter_models.yaml
|
|
@@ -330,6 +330,53 @@ class TestLoadApiKeysStatic(unittest.TestCase):
|
|
|
330
330
|
|
|
331
331
|
self.assertIn("Missing API key", str(ctx.exception))
|
|
332
332
|
|
|
333
|
+
@patch.dict(os.environ, {
|
|
334
|
+
# The fleet convention (0.3.2): the first NUM indices are the
|
|
335
|
+
# sanctioned live keys; keys parked at higher indices beyond a gap
|
|
336
|
+
# are dead or spare ON PURPOSE (mirrors William's GROQ fleet:
|
|
337
|
+
# 1..24 live, 37..46 parked dead). NUM is authoritative.
|
|
338
|
+
"NUM_GAPPY": "24",
|
|
339
|
+
**{f"GAPPY_API_KEY_{i}": f"key-{i}" for i in range(1, 25)},
|
|
340
|
+
**{f"GAPPY_API_KEY_{i}": f"key-{i}" for i in range(37, 47)},
|
|
341
|
+
})
|
|
342
|
+
@patch('keycycle.multi_client_wrapper.load_dotenv')
|
|
343
|
+
def test_load_keys_num_caps_out_parked_keys(self, mock_dotenv):
|
|
344
|
+
"""Only the first NUM sorted indices load; parked keys stay out."""
|
|
345
|
+
keys = MultiClientWrapper.load_api_keys("gappy")
|
|
346
|
+
|
|
347
|
+
self.assertEqual(len(keys), 24)
|
|
348
|
+
self.assertEqual(keys[0], "key-1")
|
|
349
|
+
self.assertEqual(keys[-1], "key-24")
|
|
350
|
+
|
|
351
|
+
@patch.dict(os.environ, {
|
|
352
|
+
# A gap INSIDE the sanctioned range is tolerated: index 2 retired,
|
|
353
|
+
# NUM still says 3, so the loader takes the first 3 that exist
|
|
354
|
+
# (1, 3, 4) rather than crashing or stopping at the gap.
|
|
355
|
+
"NUM_HOLEY": "3",
|
|
356
|
+
"HOLEY_API_KEY_1": "key-1",
|
|
357
|
+
"HOLEY_API_KEY_3": "key-3",
|
|
358
|
+
"HOLEY_API_KEY_4": "key-4",
|
|
359
|
+
"HOLEY_API_KEY_9": "key-9",
|
|
360
|
+
})
|
|
361
|
+
@patch('keycycle.multi_client_wrapper.load_dotenv')
|
|
362
|
+
def test_load_keys_tolerates_gap_inside_sanctioned_range(self, mock_dotenv):
|
|
363
|
+
keys = MultiClientWrapper.load_api_keys("holey")
|
|
364
|
+
self.assertEqual(keys, ["key-1", "key-3", "key-4"])
|
|
365
|
+
|
|
366
|
+
@patch.dict(os.environ, {
|
|
367
|
+
"NUM_SPARSE": "5",
|
|
368
|
+
"SPARSE_API_KEY_1": "key-1",
|
|
369
|
+
"SPARSE_API_KEY_3": "key-3",
|
|
370
|
+
})
|
|
371
|
+
@patch('keycycle.multi_client_wrapper.load_dotenv')
|
|
372
|
+
def test_load_keys_logs_warning_on_count_mismatch(self, mock_dotenv):
|
|
373
|
+
"""A mismatch between NUM_{PROVIDER} and keys found is logged loudly."""
|
|
374
|
+
with self.assertLogs('keycycle.core.utils', level='WARNING') as ctx:
|
|
375
|
+
keys = MultiClientWrapper.load_api_keys("sparse")
|
|
376
|
+
|
|
377
|
+
self.assertEqual(keys, ["key-1", "key-3"])
|
|
378
|
+
self.assertTrue(any("NUM_SPARSE" in msg for msg in ctx.output))
|
|
379
|
+
|
|
333
380
|
|
|
334
381
|
class TestKwargFiltering(unittest.TestCase):
|
|
335
382
|
"""Test the kwarg filtering functionality (introspection + manual exclusion)."""
|
|
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
|
|
File without changes
|
|
File without changes
|