keymesh 0.1.2a0__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.
keymesh/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ KeyMesh — Credential Orchestration Runtime for AI API Systems.
3
+
4
+ A lightweight, concurrency-safe API key pool manager and scheduler.
5
+ KeyMesh is NOT a proxy, SDK wrapper, or HTTP gateway.
6
+ It is purely a credential allocator and rate-limit-aware scheduler.
7
+
8
+ Usage:
9
+ from keymesh import KeyPool, SchedulerStrategy
10
+
11
+ pool = KeyPool(
12
+ keys=["sk-key1", "sk-key2", "sk-key3"],
13
+ strategy=SchedulerStrategy.LEAST_BUSY,
14
+ )
15
+
16
+ key = await pool.acquire()
17
+ # ... use key in your own SDK/HTTP client ...
18
+ await pool.release(key)
19
+ """
20
+
21
+ from keymesh.pool.pool import KeyPool
22
+ from keymesh.pool.sync_pool import SyncKeyPool
23
+ from keymesh.scheduler.base import SchedulerStrategy
24
+ from keymesh.state.key_state import KeyState
25
+ from keymesh.state.sync_key_state import SyncKeyState
26
+ from keymesh.exceptions import (
27
+ KeyMeshError,
28
+ NoAvailableKeyError,
29
+ KeyCooldownError,
30
+ KeyExhaustedError,
31
+ )
32
+
33
+ __all__ = [
34
+ "KeyPool",
35
+ "SyncKeyPool",
36
+ "SchedulerStrategy",
37
+ "KeyState",
38
+ "SyncKeyState",
39
+ "KeyMeshError",
40
+ "NoAvailableKeyError",
41
+ "KeyCooldownError",
42
+ "KeyExhaustedError",
43
+ ]
44
+
45
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """keymesh.concurrency package."""
2
+ from keymesh.concurrency.semaphores import SemaphoreGroup
3
+
4
+ __all__ = ["SemaphoreGroup"]
@@ -0,0 +1,47 @@
1
+ """
2
+ Concurrency utilities for KeyMesh.
3
+
4
+ Provides a SemaphoreGroup for per-key concurrency limits — optional,
5
+ for users who want to cap in-flight requests per key.
6
+ """
7
+
8
+ import asyncio
9
+
10
+
11
+ class SemaphoreGroup:
12
+ """
13
+ Per-key asyncio semaphore pool.
14
+
15
+ Use this when you want to cap the number of concurrent in-flight
16
+ requests per API key (e.g., a provider that allows max 5 concurrent calls).
17
+
18
+ Semaphores are created lazily on first use inside a running event loop,
19
+ avoiding "Future attached to a different loop" errors when this object
20
+ is instantiated at module level or before ``asyncio.run()`` is called.
21
+
22
+ Example
23
+ -------
24
+ ```python
25
+ sem_group = SemaphoreGroup(max_concurrent=5)
26
+
27
+ key = await pool.acquire()
28
+ async with sem_group.acquire(key):
29
+ response = await client.call(api_key=key)
30
+ await pool.release(key)
31
+ ```
32
+ """
33
+
34
+ def __init__(self, max_concurrent: int = 10) -> None:
35
+ self._max = max_concurrent
36
+ self._sems: dict[str, asyncio.Semaphore] = {}
37
+
38
+ def acquire(self, key: str) -> asyncio.Semaphore:
39
+ """Return (or lazily create) the semaphore for `key`.
40
+
41
+ Must be called from within a running async context so that the
42
+ semaphore is bound to the correct event loop.
43
+ """
44
+ if key not in self._sems:
45
+ # Created inside a coroutine: the running loop is guaranteed.
46
+ self._sems[key] = asyncio.Semaphore(self._max)
47
+ return self._sems[key]
@@ -0,0 +1,4 @@
1
+ """keymesh.cooldown package."""
2
+ from keymesh.cooldown.manager import CooldownManager
3
+
4
+ __all__ = ["CooldownManager"]
@@ -0,0 +1,52 @@
1
+ """
2
+ Cooldown manager — tracks and enforces per-key cooldown windows.
3
+
4
+ KeyMesh never sleeps waiting for cooldowns. Instead, unavailable keys
5
+ are filtered out at acquire-time and re-admitted when time has elapsed.
6
+ """
7
+
8
+ import time
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from keymesh.state.key_state import KeyState
13
+
14
+
15
+ class CooldownManager:
16
+ """
17
+ Stateless helper for cooldown evaluation.
18
+
19
+ All actual cooldown state lives inside KeyState.
20
+ This class provides higher-level helpers used by the pool.
21
+ """
22
+
23
+ @staticmethod
24
+ def apply(key_state: "KeyState", duration: float = 60.0) -> None:
25
+ """
26
+ Immediately apply a cooldown window to a key (synchronous, for internal use).
27
+
28
+ .. deprecated::
29
+ This method mutates ``cooldown_until`` directly without acquiring
30
+ the key's internal ``asyncio.Lock``, making it unsafe under
31
+ concurrency. Use ``await key_state.apply_cooldown(duration)``
32
+ in async contexts instead.
33
+ """
34
+ import warnings
35
+
36
+ warnings.warn(
37
+ "CooldownManager.apply() is not concurrency-safe. "
38
+ "Use `await key_state.apply_cooldown(duration)` in async code.",
39
+ DeprecationWarning,
40
+ stacklevel=2,
41
+ )
42
+ key_state.cooldown_until = time.monotonic() + duration
43
+
44
+ @staticmethod
45
+ def is_expired(key_state: "KeyState") -> bool:
46
+ """Return True if cooldown has elapsed (key may be re-admitted)."""
47
+ return time.monotonic() >= key_state.cooldown_until
48
+
49
+ @staticmethod
50
+ def remaining(key_state: "KeyState") -> float:
51
+ """Seconds until cooldown expires. Returns 0 if not cooling down."""
52
+ return max(0.0, key_state.cooldown_until - time.monotonic())
keymesh/exceptions.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ KeyMesh custom exceptions.
3
+ """
4
+
5
+
6
+ class KeyMeshError(Exception):
7
+ """Base exception for all KeyMesh errors."""
8
+
9
+
10
+ class NoAvailableKeyError(KeyMeshError):
11
+ """Raised when no API key is currently available (all cooling down or failed)."""
12
+
13
+
14
+ class KeyCooldownError(KeyMeshError):
15
+ """Raised when a specific key is in cooldown and cannot be used."""
16
+
17
+ def __init__(self, key: str, cooldown_remaining: float) -> None:
18
+ self.key = key
19
+ self.cooldown_remaining = cooldown_remaining
20
+ super().__init__(
21
+ f"Key ...{key[-6:]} is in cooldown for {cooldown_remaining:.1f}s more."
22
+ )
23
+
24
+
25
+ class KeyExhaustedError(KeyMeshError):
26
+ """Raised when a key has exceeded its failure threshold and is removed from the pool."""
27
+
28
+ def __init__(self, key: str) -> None:
29
+ self.key = key
30
+ super().__init__(f"Key ...{key[-6:]} has been exhausted and removed from pool.")
@@ -0,0 +1,4 @@
1
+ """keymesh.metrics package."""
2
+ from keymesh.metrics.pool_metrics import PoolMetrics
3
+
4
+ __all__ = ["PoolMetrics"]
@@ -0,0 +1,52 @@
1
+ """
2
+ Runtime metrics aggregation for the pool.
3
+ """
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class PoolMetrics:
11
+ """Aggregated runtime metrics for a KeyPool instance."""
12
+
13
+ total_acquires: int = 0
14
+ total_releases: int = 0
15
+ total_failures: int = 0
16
+ total_cooldowns: int = 0
17
+ total_retries: int = 0
18
+ no_key_available_count: int = 0
19
+ _started_at: float = field(default_factory=time.monotonic, init=False)
20
+
21
+ def record_acquire(self) -> None:
22
+ self.total_acquires += 1
23
+
24
+ def record_release(self) -> None:
25
+ self.total_releases += 1
26
+
27
+ def record_failure(self) -> None:
28
+ self.total_failures += 1
29
+
30
+ def record_cooldown(self) -> None:
31
+ self.total_cooldowns += 1
32
+
33
+ def record_retry(self) -> None:
34
+ self.total_retries += 1
35
+
36
+ def record_no_key(self) -> None:
37
+ self.no_key_available_count += 1
38
+
39
+ @property
40
+ def uptime_seconds(self) -> float:
41
+ return time.monotonic() - self._started_at
42
+
43
+ def snapshot(self) -> dict[str, object]:
44
+ return {
45
+ "uptime_seconds": round(self.uptime_seconds, 2),
46
+ "total_acquires": self.total_acquires,
47
+ "total_releases": self.total_releases,
48
+ "total_failures": self.total_failures,
49
+ "total_cooldowns": self.total_cooldowns,
50
+ "total_retries": self.total_retries,
51
+ "no_key_available_count": self.no_key_available_count,
52
+ }
@@ -0,0 +1,5 @@
1
+ """keymesh.pool package."""
2
+ from keymesh.pool.pool import KeyPool
3
+ from keymesh.pool.sync_pool import SyncKeyPool
4
+
5
+ __all__ = ["KeyPool", "SyncKeyPool"]
keymesh/pool/pool.py ADDED
@@ -0,0 +1,251 @@
1
+ """
2
+ KeyPool — the central public interface of KeyMesh.
3
+
4
+ Responsibilities:
5
+ - Hold and manage a pool of KeyState objects
6
+ - Delegate key selection to the configured scheduler
7
+ - Enforce cooldowns, failure thresholds, and retry logic
8
+ - Surface a minimal public API: acquire / release / mark_failed / mark_rate_limited
9
+
10
+ KeyPool is framework-agnostic. It never sends HTTP requests or wraps any SDK.
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ from typing import Any, Sequence
16
+
17
+ from keymesh.exceptions import KeyExhaustedError, NoAvailableKeyError
18
+ from keymesh.metrics.pool_metrics import PoolMetrics
19
+ from keymesh.scheduler import BaseScheduler, SchedulerStrategy, build_scheduler
20
+ from keymesh.state.key_state import KeyState
21
+ from keymesh.storage.base import BaseStorage
22
+ from keymesh.storage.memory import MemoryStorage
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class KeyPool:
28
+ """
29
+ Manages a collection of API keys with scheduling, cooldown, and retry support.
30
+
31
+ Example
32
+ -------
33
+ ```python
34
+ pool = KeyPool(keys=["sk-a", "sk-b", "sk-c"])
35
+
36
+ key = await pool.acquire()
37
+ try:
38
+ response = await my_client.call(api_key=key)
39
+ await pool.release(key, latency=response.elapsed)
40
+ except RateLimitError:
41
+ await pool.mark_rate_limited(key, cooldown=60.0)
42
+ except Exception:
43
+ await pool.mark_failed(key)
44
+ ```
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ keys: Sequence[str],
50
+ *,
51
+ strategy: SchedulerStrategy = SchedulerStrategy.LEAST_BUSY,
52
+ scheduler: BaseScheduler | None = None,
53
+ storage: BaseStorage | None = None,
54
+ default_cooldown: float = 60.0,
55
+ max_failures: int = 10,
56
+ acquire_timeout: float = 30.0,
57
+ ) -> None:
58
+ """
59
+ Parameters
60
+ ----------
61
+ keys:
62
+ List of raw API key strings. Must be non-empty.
63
+ strategy:
64
+ Scheduling strategy to use. Ignored if `scheduler` is provided.
65
+ scheduler:
66
+ Custom scheduler instance. Overrides `strategy`.
67
+ storage:
68
+ Persistence backend. Defaults to in-memory (no persistence).
69
+ default_cooldown:
70
+ Default cooldown duration in seconds when mark_rate_limited is called.
71
+ max_failures:
72
+ Consecutive failures before a key is considered exhausted.
73
+ acquire_timeout:
74
+ Max seconds to wait for an available key before raising NoAvailableKeyError.
75
+ """
76
+ if not keys:
77
+ raise ValueError("KeyPool requires at least one API key.")
78
+
79
+ self._states: dict[str, KeyState] = {
80
+ k: KeyState(key=k, max_failures=max_failures) for k in keys
81
+ }
82
+ self._scheduler: BaseScheduler = scheduler or build_scheduler(strategy)
83
+ self._storage: BaseStorage = storage or MemoryStorage()
84
+ self._default_cooldown = default_cooldown
85
+ self._max_failures = max_failures
86
+ self._acquire_timeout = acquire_timeout
87
+ self._metrics = PoolMetrics()
88
+ self._pool_lock = asyncio.Lock()
89
+
90
+ logger.info(
91
+ "KeyPool initialised: %d keys, strategy=%s",
92
+ len(keys),
93
+ self._scheduler.strategy,
94
+ )
95
+
96
+ # ── Public API ────────────────────────────────────────────────────────────────
97
+
98
+ async def acquire(self) -> str:
99
+ """
100
+ Acquire an available API key.
101
+
102
+ Blocks (up to `acquire_timeout`) until a key becomes available.
103
+
104
+ Returns
105
+ -------
106
+ str
107
+ The raw API key string to inject into your HTTP client / SDK.
108
+
109
+ Raises
110
+ ------
111
+ NoAvailableKeyError
112
+ If no key becomes available within `acquire_timeout` seconds.
113
+ """
114
+ loop = asyncio.get_running_loop()
115
+ deadline = loop.time() + self._acquire_timeout
116
+
117
+ while True:
118
+ key_state = await self._try_acquire()
119
+ if key_state is not None:
120
+ self._metrics.record_acquire()
121
+ logger.debug("Acquired key ...%s", key_state.key[-6:])
122
+ return key_state.key
123
+
124
+ self._metrics.record_no_key()
125
+ remaining = deadline - loop.time()
126
+ if remaining <= 0:
127
+ raise NoAvailableKeyError(
128
+ "No API keys are currently available. "
129
+ "All keys are either cooling down or exhausted."
130
+ )
131
+
132
+ # Brief sleep before retrying to avoid busy-loop
133
+ await asyncio.sleep(min(0.5, remaining))
134
+
135
+ async def release(self, key: str, *, latency: float = 0.0) -> None:
136
+ """
137
+ Release a key after a successful API call.
138
+
139
+ Parameters
140
+ ----------
141
+ key:
142
+ The key string returned by acquire().
143
+ latency:
144
+ Optional: elapsed seconds for the call (used for weighted scheduling).
145
+ """
146
+ state = self._resolve(key)
147
+ await state.record_success(latency)
148
+ self._metrics.record_release()
149
+ await self._storage.save(key, state.snapshot())
150
+ logger.debug("Released key ...%s (latency=%.3fs)", key[-6:], latency)
151
+
152
+ async def mark_failed(self, key: str) -> None:
153
+ """
154
+ Mark a key as failed (non-rate-limit error).
155
+
156
+ After `max_failures` calls, the key is marked exhausted and excluded
157
+ from future scheduling.
158
+
159
+ Raises
160
+ ------
161
+ KeyExhaustedError
162
+ If the key has now reached the failure threshold.
163
+ """
164
+ state = self._resolve(key)
165
+ await state.record_failure()
166
+ self._metrics.record_failure()
167
+ await self._storage.save(key, state.snapshot())
168
+ logger.warning("Key ...%s marked failed (total=%d)", key[-6:], state.failure_count)
169
+
170
+ if state.is_exhausted:
171
+ raise KeyExhaustedError(key)
172
+
173
+ async def mark_rate_limited(self, key: str, *, cooldown: float | None = None) -> None:
174
+ """
175
+ Mark a key as rate-limited (HTTP 429).
176
+
177
+ The key will be excluded from scheduling until the cooldown elapses.
178
+ Resets the key's consecutive failure counter — a rate-limited key is
179
+ still valid, just throttled.
180
+
181
+ Parameters
182
+ ----------
183
+ cooldown:
184
+ Cooldown duration in seconds. Defaults to pool's `default_cooldown`.
185
+ """
186
+ duration = cooldown if cooldown is not None else self._default_cooldown
187
+ state = self._resolve(key)
188
+ await state.apply_cooldown(duration)
189
+ self._metrics.record_cooldown()
190
+ await self._storage.save(key, state.snapshot())
191
+ logger.warning(
192
+ "Key ...%s rate-limited, cooling down for %.1fs", key[-6:], duration
193
+ )
194
+
195
+ async def add_key(self, key: str) -> None:
196
+ """
197
+ Add or reset a key in the pool at runtime.
198
+
199
+ Use this to:
200
+ - Re-admit an exhausted key after rotating/refreshing it.
201
+ - Inject a brand-new key into a running pool without restarting.
202
+
203
+ Parameters
204
+ ----------
205
+ key:
206
+ The raw API key string to add or reset.
207
+ """
208
+ async with self._pool_lock:
209
+ self._states[key] = KeyState(key=key, max_failures=self._max_failures)
210
+ logger.info("Key ...%s added/reset in pool", key[-6:])
211
+
212
+ # ── Diagnostics ───────────────────────────────────────────────────────────────
213
+
214
+ def status(self) -> dict[str, Any]:
215
+ """Return a full status snapshot of the pool and all key states."""
216
+ return {
217
+ "pool_metrics": self._metrics.snapshot(),
218
+ "scheduler": self._scheduler.strategy,
219
+ "keys": [ks.snapshot() for ks in self._states.values()],
220
+ }
221
+
222
+ def available_count(self) -> int:
223
+ """Number of keys currently available for scheduling."""
224
+ return sum(1 for ks in self._states.values() if ks.is_available)
225
+
226
+ # ── Internal ──────────────────────────────────────────────────────────────────
227
+
228
+ async def _try_acquire(self) -> KeyState | None:
229
+ """
230
+ Ask the scheduler to pick one available key.
231
+
232
+ Returns None if no key is available right now.
233
+ """
234
+ async with self._pool_lock:
235
+ candidates = [ks for ks in self._states.values() if ks.is_available]
236
+ key_state = self._scheduler.select(candidates)
237
+ if key_state is not None:
238
+ await key_state.increment_active()
239
+ return key_state
240
+
241
+ def _resolve(self, key: str) -> KeyState:
242
+ """Look up a KeyState by raw key string. Raises KeyError if unknown."""
243
+ try:
244
+ return self._states[key]
245
+ except KeyError as exc:
246
+ raise KeyError(f"Unknown key '...{key[-6:]}' — was it added to this pool?") from exc
247
+
248
+ async def close(self) -> None:
249
+ """Release storage resources. Call when pool is no longer needed."""
250
+ await self._storage.close()
251
+ logger.info("KeyPool closed.")