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.
Files changed (51) hide show
  1. {keycycle-0.3.0 → keycycle-0.3.2}/PKG-INFO +1 -1
  2. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/adapters/openai_adapter.py +1 -0
  3. keycycle-0.3.2/keycycle/config/models/mistral.yaml +13 -0
  4. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models.py +5 -2
  5. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/utils.py +45 -12
  6. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/PKG-INFO +1 -1
  7. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/SOURCES.txt +1 -0
  8. {keycycle-0.3.0 → keycycle-0.3.2}/pyproject.toml +1 -1
  9. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_multi_client_wrapper.py +47 -0
  10. {keycycle-0.3.0 → keycycle-0.3.2}/MANIFEST.in +0 -0
  11. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/__init__.py +0 -0
  12. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/adapters/__init__.py +0 -0
  13. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/adapters/generic_adapter.py +0 -0
  14. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/__init__.py +0 -0
  15. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/constants.py +0 -0
  16. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/dataclasses.py +0 -0
  17. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/enums.py +0 -0
  18. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/loader.py +0 -0
  19. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/log_config.py +0 -0
  20. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/cerebras.yaml +0 -0
  21. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/cohere.yaml +0 -0
  22. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/gemini.yaml +0 -0
  23. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/groq.yaml +0 -0
  24. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/moonshot.yaml +0 -0
  25. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/openrouter.yaml +0 -0
  26. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/config/models/openrouter_models.yaml +0 -0
  27. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/__init__.py +0 -0
  28. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/backoff.py +0 -0
  29. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/core/exceptions.py +0 -0
  30. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/key_rotation/__init__.py +0 -0
  31. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/key_rotation/rotating_mixin.py +0 -0
  32. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/key_rotation/rotation_manager.py +0 -0
  33. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/legacy_multi_provider_wrapper.py +0 -0
  34. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/multi_client_wrapper.py +0 -0
  35. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/py.typed +0 -0
  36. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/usage/__init__.py +0 -0
  37. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/usage/db_logic.py +0 -0
  38. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/usage/usage_logger.py +0 -0
  39. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle/utils.py +0 -0
  40. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/dependency_links.txt +0 -0
  41. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/requires.txt +0 -0
  42. {keycycle-0.3.0 → keycycle-0.3.2}/keycycle.egg-info/top_level.txt +0 -0
  43. {keycycle-0.3.0 → keycycle-0.3.2}/setup.cfg +0 -0
  44. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_api_key_pinning.py +0 -0
  45. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_api_key_retrieval.py +0 -0
  46. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_cerebras_direct.py +0 -0
  47. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_custom_key_limits.py +0 -0
  48. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_payment_required_rotation.py +0 -0
  49. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_rate_limit_logic.py +0 -0
  50. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_specific_key_retrieval.py +0 -0
  51. {keycycle-0.3.0 → keycycle-0.3.2}/tests/test_streams_recording.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keycycle
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A thread-safe key rotation and rate limiting manager for API keys.
5
5
  Author-email: William Jin <jinwilliam.jin@gmail.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -43,6 +43,7 @@ PROVIDER_BASE_URLS = {
43
43
  "groq": "https://api.groq.com/openai/v1",
44
44
  "cohere": "https://api.cohere.ai/compatibility/v1",
45
45
  "moonshot": "https://api.moonshot.ai/v1",
46
+ "mistral": "https://api.mistral.ai/v1",
46
47
  }
47
48
 
48
49
  class BaseRotatingClient:
@@ -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
- num_keys_var = f"NUM_{provider.upper()}"
317
- num_keys = os.getenv(num_keys_var)
318
- if not num_keys:
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
- num_keys = int(num_keys)
322
+ expected_count = int(num_keys_raw)
322
323
  except ValueError:
323
- raise ValueError(f"'{num_keys_var}' must be an integer, got: {num_keys}")
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 range(1, num_keys + 1):
327
- key_var = f"{provider.upper()}_API_KEY_{i}"
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"{provider.upper()}_{param.upper()}_{i}")
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keycycle
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A thread-safe key rotation and rate limiting manager for API keys.
5
5
  Author-email: William Jin <jinwilliam.jin@gmail.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keycycle"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  authors = [
9
9
  { name="William Jin", email="jinwilliam.jin@gmail.com" },
10
10
  ]
@@ -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