keycycle 0.3.2__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.
@@ -0,0 +1,381 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from typing import List, Any, Optional, Callable, Generator, AsyncGenerator
5
+
6
+ from ..config.dataclasses import KeyUsage, RateLimits
7
+ from ..config.constants import (
8
+ TEMP_RATE_LIMIT_MAX_RETRIES,
9
+ TEMP_RATE_LIMIT_INITIAL_DELAY,
10
+ TEMP_RATE_LIMIT_MAX_DELAY,
11
+ TEMP_RATE_LIMIT_MULTIPLIER,
12
+ KEY_ROTATION_DELAY_SECONDS,
13
+ )
14
+ from ..core.utils import (
15
+ is_rate_limit_error,
16
+ is_temporary_rate_limit_error,
17
+ is_payment_required_error,
18
+ get_key_suffix,
19
+ )
20
+ from ..core.backoff import ExponentialBackoff, BackoffConfig
21
+ from ..key_rotation.rotation_manager import RotatingKeyManager
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ try:
26
+ import openai
27
+ from openai import OpenAI, AsyncOpenAI, RateLimitError
28
+ HAS_OPENAI = True
29
+ except ImportError:
30
+ HAS_OPENAI = False
31
+ # Generic placeholders
32
+ class OpenAI:
33
+ pass
34
+ class AsyncOpenAI:
35
+ pass
36
+ RateLimitError = Exception
37
+
38
+ PROVIDER_BASE_URLS = {
39
+ "openai": "https://api.openai.com/v1",
40
+ "openrouter": "https://openrouter.ai/api/v1",
41
+ "gemini": "https://generativelanguage.googleapis.com/v1beta/openai/",
42
+ "cerebras": "https://api.cerebras.ai/v1",
43
+ "groq": "https://api.groq.com/openai/v1",
44
+ "cohere": "https://api.cohere.ai/compatibility/v1",
45
+ "moonshot": "https://api.moonshot.ai/v1",
46
+ "mistral": "https://api.mistral.ai/v1",
47
+ }
48
+
49
+ class BaseRotatingClient:
50
+ def __init__(self,
51
+ manager: RotatingKeyManager,
52
+ limit_resolver: Callable[[str, Optional[str]], RateLimits],
53
+ default_model: str,
54
+ estimated_tokens: int = 1000,
55
+ max_retries: int = 5,
56
+ base_url: Optional[str] = None,
57
+ provider: Optional[str] = None,
58
+ client_kwargs: dict = None
59
+ ):
60
+ """
61
+ Initialize the rotating client.
62
+
63
+ Args:
64
+ manager: The key rotation manager
65
+ limit_resolver: Function to resolve rate limits for a model
66
+ default_model: Default model to use
67
+ estimated_tokens: Estimated tokens per request
68
+ max_retries: Maximum number of retries on rate limit
69
+ base_url: Base URL for the API (takes precedence over provider)
70
+ provider: Provider name (openai, openrouter, gemini, cerebras, groq)
71
+ client_kwargs: Additional kwargs to pass to OpenAI client
72
+ """
73
+
74
+ if not HAS_OPENAI:
75
+ raise ImportError("The 'openai' library is required. Install with `pip install openai`.")
76
+
77
+ self.manager = manager
78
+ self.limit_resolver = limit_resolver
79
+ self.default_model = default_model
80
+ self.estimated_tokens = estimated_tokens
81
+ self.max_retries = max_retries
82
+ self.client_kwargs = client_kwargs or {}
83
+
84
+ if base_url:
85
+ self.base_url = base_url
86
+ elif provider:
87
+ provider_lower = provider.lower()
88
+ if provider_lower not in PROVIDER_BASE_URLS:
89
+ raise ValueError(
90
+ f"Unknown provider: {provider}. "
91
+ f"Valid providers: {', '.join(PROVIDER_BASE_URLS.keys())}"
92
+ )
93
+ self.base_url = PROVIDER_BASE_URLS[provider_lower]
94
+ else:
95
+ self.base_url = None # Will use OpenAI's default
96
+
97
+ if self.base_url:
98
+ self.client_kwargs['base_url'] = self.base_url
99
+
100
+ def _record_usage(self, key_usage: KeyUsage, model_id: str, actual_tokens: int) -> None:
101
+ self.manager.record_usage(
102
+ key_obj=key_usage,
103
+ model_id=model_id,
104
+ actual_tokens=actual_tokens,
105
+ estimated_tokens=self.estimated_tokens
106
+ )
107
+
108
+ def _extract_usage(self, response: Any) -> int:
109
+ try:
110
+ if hasattr(response, 'usage') and response.usage:
111
+ return response.usage.total_tokens
112
+ except (AttributeError, TypeError):
113
+ pass
114
+ return 0
115
+
116
+ # --- SYNC IMPLEMENTATION ---
117
+
118
+ class RotatingOpenAIClient(BaseRotatingClient):
119
+ def _get_fresh_client(self, api_key: str) -> OpenAI:
120
+ return OpenAI(api_key=api_key, **self.client_kwargs)
121
+
122
+ def __getattr__(self, name):
123
+ return SyncProxyHelper(self, [name])
124
+
125
+ def _execute(self, path: List[str], args, kwargs):
126
+ model_id = kwargs.get('model', self.default_model)
127
+ if 'model' not in kwargs:
128
+ kwargs['model'] = model_id
129
+ limits = self.limit_resolver(model_id, None)
130
+
131
+ if kwargs.get('stream', False) and 'stream_options' not in kwargs:
132
+ kwargs['stream_options'] = {"include_usage": True}
133
+
134
+ for attempt in range(self.max_retries + 1):
135
+ key_usage = self.manager.get_key(model_id, limits, self.estimated_tokens)
136
+ if not key_usage:
137
+ raise RuntimeError(f"No available keys for {model_id}")
138
+
139
+ # Create backoff for temporary rate limits
140
+ temp_backoff = ExponentialBackoff(BackoffConfig(
141
+ initial_interval=TEMP_RATE_LIMIT_INITIAL_DELAY,
142
+ max_interval=TEMP_RATE_LIMIT_MAX_DELAY,
143
+ multiplier=TEMP_RATE_LIMIT_MULTIPLIER,
144
+ ))
145
+
146
+ for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
147
+ try:
148
+ real_client = self._get_fresh_client(key_usage.api_key)
149
+
150
+ target = real_client
151
+ for p in path:
152
+ target = getattr(target, p)
153
+
154
+ result = target(*args, **kwargs)
155
+
156
+ if kwargs.get('stream', False):
157
+ return self._wrap_stream(result, key_usage, model_id)
158
+
159
+ self._record_usage(key_usage, model_id, self._extract_usage(result))
160
+ return result
161
+
162
+ except Exception as e:
163
+ # Check for temporary rate limit first - retry with SAME key
164
+ if is_temporary_rate_limit_error(e) and temp_attempt < TEMP_RATE_LIMIT_MAX_RETRIES:
165
+ delay = temp_backoff.get_next_interval()
166
+ logger.info(
167
+ "Temporary rate limit on key ...%s for %s. Waiting %.1fs (%d/%d).",
168
+ get_key_suffix(key_usage.api_key), model_id, delay,
169
+ temp_attempt + 1, TEMP_RATE_LIMIT_MAX_RETRIES
170
+ )
171
+ time.sleep(delay)
172
+ continue # Retry with SAME key
173
+
174
+ # Payment required (quota/credits exhausted) - key is dead
175
+ # for this process, rotate to next key
176
+ if is_payment_required_error(e) and attempt < self.max_retries:
177
+ logger.warning(
178
+ "402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
179
+ model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
180
+ )
181
+ key_usage.mark_dead()
182
+ self.manager.force_rotate_index()
183
+ time.sleep(KEY_ROTATION_DELAY_SECONDS)
184
+ break # Break inner loop, continue outer loop with new key
185
+
186
+ # Hard rate limit - rotate to next key
187
+ if is_rate_limit_error(e) and attempt < self.max_retries:
188
+ logger.warning(
189
+ "429/RateLimit hit for %s on key ...%s. Rotating. (Attempt %d/%d)",
190
+ model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
191
+ )
192
+ key_usage.trigger_cooldown()
193
+ self.manager.force_rotate_index()
194
+ time.sleep(KEY_ROTATION_DELAY_SECONDS)
195
+ break # Break inner loop, continue outer loop with new key
196
+
197
+ self._record_usage(key_usage, model_id, 0)
198
+ raise
199
+ else:
200
+ # Inner loop exhausted without success - continue to next key
201
+ if attempt < self.max_retries:
202
+ logger.warning(
203
+ "Temporary rate limit retries exhausted for key ...%s. Rotating.",
204
+ get_key_suffix(key_usage.api_key)
205
+ )
206
+ key_usage.trigger_cooldown()
207
+ self.manager.force_rotate_index()
208
+ continue
209
+
210
+ raise RuntimeError(f"All retry attempts exhausted for {model_id}")
211
+
212
+ def _wrap_stream(self, generator: Generator, key_usage: KeyUsage, model_id: str):
213
+ final_tokens = 0
214
+ try:
215
+ for chunk in generator:
216
+ if hasattr(chunk, 'usage') and chunk.usage:
217
+ final_tokens = chunk.usage.total_tokens
218
+ yield chunk
219
+ except Exception as e:
220
+ if is_payment_required_error(e):
221
+ logger.warning(
222
+ "402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
223
+ model_id, get_key_suffix(key_usage.api_key)
224
+ )
225
+ key_usage.mark_dead()
226
+ self.manager.force_rotate_index()
227
+ elif is_rate_limit_error(e):
228
+ logger.warning(
229
+ "Rate limit hit during streaming for %s on key ...%s.",
230
+ model_id, get_key_suffix(key_usage.api_key)
231
+ )
232
+ key_usage.trigger_cooldown()
233
+ self.manager.force_rotate_index()
234
+ raise
235
+ finally:
236
+ self._record_usage(key_usage, model_id, final_tokens)
237
+
238
+ class SyncProxyHelper:
239
+ def __init__(self, client: RotatingOpenAIClient, path: List[str]):
240
+ self.client = client
241
+ self.path = path
242
+
243
+ def __getattr__(self, name):
244
+ return SyncProxyHelper(self.client, self.path + [name])
245
+
246
+ def __call__(self, *args, **kwargs):
247
+ return self.client._execute(self.path, args, kwargs)
248
+
249
+
250
+ # --- ASYNC IMPLEMENTATION ---
251
+
252
+ class RotatingAsyncOpenAIClient(BaseRotatingClient):
253
+ def _get_fresh_client(self, api_key: str) -> AsyncOpenAI:
254
+ return AsyncOpenAI(api_key=api_key, **self.client_kwargs)
255
+
256
+ def __getattr__(self, name):
257
+ return AsyncProxyHelper(self, [name])
258
+
259
+ async def _execute(self, path: List[str], args, kwargs: dict):
260
+ model_id = kwargs.get('model', self.default_model)
261
+ if 'model' not in kwargs:
262
+ kwargs['model'] = model_id
263
+ limits = self.limit_resolver(model_id, None)
264
+
265
+ if kwargs.get('stream', False) and 'stream_options' not in kwargs:
266
+ kwargs['stream_options'] = {"include_usage": True}
267
+
268
+ for attempt in range(self.max_retries + 1):
269
+ key_usage = self.manager.get_key(model_id, limits, self.estimated_tokens)
270
+ if not key_usage:
271
+ raise RuntimeError(f"No available keys for {model_id}")
272
+
273
+ # Create backoff for temporary rate limits
274
+ temp_backoff = ExponentialBackoff(BackoffConfig(
275
+ initial_interval=TEMP_RATE_LIMIT_INITIAL_DELAY,
276
+ max_interval=TEMP_RATE_LIMIT_MAX_DELAY,
277
+ multiplier=TEMP_RATE_LIMIT_MULTIPLIER,
278
+ ))
279
+
280
+ for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
281
+ try:
282
+ real_client = self._get_fresh_client(key_usage.api_key)
283
+
284
+ target = real_client
285
+ for p in path:
286
+ target = getattr(target, p)
287
+
288
+ result = await target(*args, **kwargs)
289
+
290
+ if kwargs.get('stream', False):
291
+ return self._wrap_stream(result, key_usage, model_id)
292
+
293
+ self._record_usage(key_usage, model_id, self._extract_usage(result))
294
+ return result
295
+
296
+ except Exception as e:
297
+ # Check for temporary rate limit first - retry with SAME key
298
+ if is_temporary_rate_limit_error(e) and temp_attempt < TEMP_RATE_LIMIT_MAX_RETRIES:
299
+ delay = temp_backoff.get_next_interval()
300
+ logger.info(
301
+ "Temporary rate limit on key ...%s for %s. Waiting %.1fs (%d/%d).",
302
+ get_key_suffix(key_usage.api_key), model_id, delay,
303
+ temp_attempt + 1, TEMP_RATE_LIMIT_MAX_RETRIES
304
+ )
305
+ await asyncio.sleep(delay)
306
+ continue # Retry with SAME key
307
+
308
+ # Payment required (quota/credits exhausted) - key is dead
309
+ # for this process, rotate to next key
310
+ if is_payment_required_error(e) and attempt < self.max_retries:
311
+ logger.warning(
312
+ "402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
313
+ model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
314
+ )
315
+ key_usage.mark_dead()
316
+ self.manager.force_rotate_index()
317
+ await asyncio.sleep(KEY_ROTATION_DELAY_SECONDS)
318
+ break # Break inner loop, continue outer loop with new key
319
+
320
+ # Hard rate limit - rotate to next key
321
+ if is_rate_limit_error(e) and attempt < self.max_retries:
322
+ logger.warning(
323
+ "429/RateLimit hit for %s on key ...%s. Rotating. (Attempt %d/%d)",
324
+ model_id, get_key_suffix(key_usage.api_key), attempt + 1, self.max_retries + 1
325
+ )
326
+ key_usage.trigger_cooldown()
327
+ self.manager.force_rotate_index()
328
+ await asyncio.sleep(KEY_ROTATION_DELAY_SECONDS)
329
+ break # Break inner loop, continue outer loop with new key
330
+
331
+ self._record_usage(key_usage, model_id, 0)
332
+ raise
333
+ else:
334
+ # Inner loop exhausted without success - continue to next key
335
+ if attempt < self.max_retries:
336
+ logger.warning(
337
+ "Temporary rate limit retries exhausted for key ...%s. Rotating.",
338
+ get_key_suffix(key_usage.api_key)
339
+ )
340
+ key_usage.trigger_cooldown()
341
+ self.manager.force_rotate_index()
342
+ continue
343
+
344
+ raise RuntimeError(f"All retry attempts exhausted for {model_id}")
345
+
346
+ async def _wrap_stream(self, generator: AsyncGenerator, key_usage: KeyUsage, model_id: str):
347
+ final_tokens = 0
348
+ try:
349
+ async for chunk in generator:
350
+ if hasattr(chunk, 'usage') and chunk.usage:
351
+ final_tokens = chunk.usage.total_tokens
352
+ yield chunk
353
+ except Exception as e:
354
+ if is_payment_required_error(e):
355
+ logger.warning(
356
+ "402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
357
+ model_id, get_key_suffix(key_usage.api_key)
358
+ )
359
+ key_usage.mark_dead()
360
+ self.manager.force_rotate_index()
361
+ elif is_rate_limit_error(e):
362
+ logger.warning(
363
+ "Rate limit hit during streaming for %s on key ...%s.",
364
+ model_id, get_key_suffix(key_usage.api_key)
365
+ )
366
+ key_usage.trigger_cooldown()
367
+ self.manager.force_rotate_index()
368
+ raise
369
+ finally:
370
+ self._record_usage(key_usage, model_id, final_tokens)
371
+
372
+ class AsyncProxyHelper:
373
+ def __init__(self, client: RotatingAsyncOpenAIClient, path: List[str]):
374
+ self.client = client
375
+ self.path = path
376
+
377
+ def __getattr__(self, name):
378
+ return AsyncProxyHelper(self.client, self.path + [name])
379
+
380
+ async def __call__(self, *args, **kwargs):
381
+ return await self.client._execute(self.path, args, kwargs)
@@ -0,0 +1,25 @@
1
+ from .dataclasses import (
2
+ RateLimits,
3
+ UsageSnapshot,
4
+ UsageBucket,
5
+ KeyUsage,
6
+ KeyDetailedStats,
7
+ KeySummary,
8
+ GlobalStats,
9
+ ModelAggregatedStats,
10
+ )
11
+ from .enums import RateLimitStrategy
12
+ from .log_config import configure_logging
13
+
14
+ __all__ = [
15
+ "RateLimits",
16
+ "UsageSnapshot",
17
+ "UsageBucket",
18
+ "KeyUsage",
19
+ "KeyDetailedStats",
20
+ "KeySummary",
21
+ "GlobalStats",
22
+ "ModelAggregatedStats",
23
+ "RateLimitStrategy",
24
+ "configure_logging",
25
+ ]
@@ -0,0 +1,35 @@
1
+ """Timing constants for key rotation and rate limiting."""
2
+
3
+ # Time windows (in seconds)
4
+ SECONDS_PER_MINUTE = 60
5
+ SECONDS_PER_HOUR = 3600
6
+ SECONDS_PER_DAY = 86400
7
+
8
+ # Cooldown configuration
9
+ DEFAULT_COOLDOWN_SECONDS = 30
10
+
11
+ # Cleanup intervals
12
+ CLEANUP_INTERVAL_SECONDS = 55
13
+
14
+ # Key wait/polling configuration
15
+ DEFAULT_POLL_INTERVAL = 0.5
16
+ MIN_POLL_INTERVAL = 0.1
17
+ MAX_POLL_INTERVAL = 5.0
18
+
19
+ # History lookback
20
+ HISTORY_LOOKBACK_SECONDS = 86400 # 24 hours
21
+
22
+ # API key suffix length for logging
23
+ KEY_SUFFIX_LENGTH = 8
24
+
25
+ # Temporary rate limit retry configuration
26
+ TEMP_RATE_LIMIT_MAX_RETRIES = 3
27
+ TEMP_RATE_LIMIT_INITIAL_DELAY = 1.0 # seconds
28
+ TEMP_RATE_LIMIT_MAX_DELAY = 10.0 # seconds
29
+ TEMP_RATE_LIMIT_MULTIPLIER = 2.0
30
+
31
+ # Key rotation delay (seconds to wait after rotating to a new key)
32
+ KEY_ROTATION_DELAY_SECONDS = 0.5
33
+
34
+ # Usage logging batch size
35
+ USAGE_LOG_BATCH_SIZE = 50
@@ -0,0 +1,225 @@
1
+ from typing import Any, Dict, List, Optional, Union
2
+ from .enums import RateLimitStrategy
3
+ from .constants import (
4
+ SECONDS_PER_MINUTE, SECONDS_PER_HOUR, SECONDS_PER_DAY,
5
+ DEFAULT_COOLDOWN_SECONDS
6
+ )
7
+ import time
8
+ from collections import deque, defaultdict
9
+ from dataclasses import dataclass, field
10
+
11
+ # --- CONFIGURATION DATA ---
12
+
13
+ @dataclass
14
+ class RateLimits:
15
+ """Rate limits for a provider"""
16
+ requests_per_minute: int
17
+ requests_per_hour: int
18
+ requests_per_day: int
19
+ tokens_per_minute: Optional[int] = None
20
+ tokens_per_hour: Optional[int] = None
21
+ tokens_per_day: Optional[int] = None
22
+
23
+ # Type alias for per-key rate limit overrides
24
+ # Can be a single RateLimits (applies to all models) or a dict mapping model_id -> RateLimits
25
+ KeyLimitOverride = Union[RateLimits, Dict[str, RateLimits]]
26
+
27
+ @dataclass
28
+ class UsageSnapshot:
29
+ """Standardized view of usage counters"""
30
+ rpm: int = 0
31
+ rph: int = 0
32
+ rpd: int = 0
33
+ tpm: int = 0
34
+ tph: int = 0
35
+ tpd: int = 0
36
+ total_requests: int = 0
37
+ total_tokens: int = 0
38
+
39
+ def __add__(self, other):
40
+ """Allow summing snapshots for aggregation"""
41
+ if not isinstance(other, UsageSnapshot): return NotImplemented
42
+ return UsageSnapshot(
43
+ self.rpm + other.rpm, self.rph + other.rph, self.rpd + other.rpd,
44
+ self.tpm + other.tpm, self.tph + other.tph, self.tpd + other.tpd,
45
+ self.total_requests + other.total_requests, self.total_tokens + other.total_tokens
46
+ )
47
+
48
+ # --- STATS DATA TRANSFER OBJECTS (DTOs) ---
49
+
50
+ @dataclass
51
+ class KeySummary:
52
+ index: int; suffix: str; snapshot: UsageSnapshot
53
+ @dataclass
54
+ class GlobalStats:
55
+ total: UsageSnapshot; keys: List[KeySummary]
56
+ @dataclass
57
+ class KeyDetailedStats:
58
+ index: int; suffix: str; total: UsageSnapshot; breakdown: Dict[str, UsageSnapshot]
59
+ @dataclass
60
+ class ModelAggregatedStats:
61
+ model_id: str; total: UsageSnapshot; keys: List[KeySummary]
62
+
63
+
64
+ # --- USAGE TRACKING ---
65
+ @dataclass
66
+ class UsageBucket:
67
+ """Tracks counters for a SINGLE model context"""
68
+ requests_minute: deque[float] = field(default_factory=deque)
69
+ requests_hour: deque[float] = field(default_factory=deque)
70
+ requests_day: deque[float] = field(default_factory=deque)
71
+
72
+ tokens_minute: deque[tuple[float, int]] = field(default_factory=deque)
73
+ tokens_hour: deque[tuple[float, int]] = field(default_factory=deque)
74
+ tokens_day: deque[tuple[float, int]] = field(default_factory=deque)
75
+
76
+ total_requests: int = 0
77
+ total_tokens: int = 0
78
+
79
+ pending_tokens: int = 0
80
+
81
+ def clean(self) -> None:
82
+ """Clean old entries based on current time"""
83
+ now = time.time()
84
+ cutoffs = (now - SECONDS_PER_MINUTE, now - SECONDS_PER_HOUR, now - SECONDS_PER_DAY)
85
+
86
+ for d, cut in zip(
87
+ [self.requests_minute, self.requests_hour, self.requests_day], cutoffs
88
+ ):
89
+ while d and d[0] <= cut: d.popleft()
90
+
91
+ for d, cut in zip(
92
+ [self.tokens_minute, self.tokens_hour, self.tokens_day], cutoffs
93
+ ):
94
+ while d and d[0][0] <= cut: d.popleft()
95
+
96
+ def add(self, tokens: int, timestamp: float):
97
+ self.requests_minute.append(timestamp)
98
+ self.requests_hour.append(timestamp)
99
+ self.requests_day.append(timestamp)
100
+ self.total_requests += 1
101
+
102
+ if tokens > 0:
103
+ self.tokens_minute.append((timestamp, tokens))
104
+ self.tokens_hour.append((timestamp, tokens))
105
+ self.tokens_day.append((timestamp, tokens))
106
+ self.total_tokens += tokens
107
+
108
+ def check_limits(self, limits: RateLimits, estimated_tokens: int) -> bool:
109
+ self.clean()
110
+ if len(self.requests_minute) >= limits.requests_per_minute: return False
111
+ if len(self.requests_hour) >= limits.requests_per_hour: return False
112
+ if len(self.requests_day) >= limits.requests_per_day: return False
113
+
114
+ current_tpm = sum(t[1] for t in self.tokens_minute) + self.pending_tokens
115
+ current_tph = sum(t[1] for t in self.tokens_hour) + self.pending_tokens
116
+ current_tpd = sum(t[1] for t in self.tokens_day) + self.pending_tokens
117
+
118
+ if limits.tokens_per_minute and (current_tpm + estimated_tokens > limits.tokens_per_minute): return False
119
+ if limits.tokens_per_hour and (current_tph + estimated_tokens > limits.tokens_per_hour): return False
120
+ if limits.tokens_per_day and (current_tpd + estimated_tokens > limits.tokens_per_day): return False
121
+
122
+ return True
123
+
124
+ def reserve(self, tokens: int):
125
+ """Lock in estimated tokens"""
126
+ self.pending_tokens += tokens
127
+
128
+ def commit(self, actual_tokens: int, reserved_tokens: int, timestamp: float):
129
+ """Remove reservation and add actual usage"""
130
+ self.pending_tokens -= reserved_tokens
131
+ if self.pending_tokens < 0:
132
+ import logging
133
+ logging.getLogger(__name__).warning(
134
+ "Pending tokens went negative (%d), clamping to 0",
135
+ self.pending_tokens
136
+ )
137
+ self.pending_tokens = 0
138
+ self.add(actual_tokens, timestamp)
139
+
140
+ def get_snapshot(self) -> UsageSnapshot:
141
+ """Return current counts as a clean snapshot"""
142
+ self.clean()
143
+ return UsageSnapshot(
144
+ rpm=len(self.requests_minute),
145
+ rph=len(self.requests_hour),
146
+ rpd=len(self.requests_day),
147
+ tpm=sum(t[1] for t in self.tokens_minute),
148
+ tph=sum(t[1] for t in self.tokens_hour),
149
+ tpd=sum(t[1] for t in self.tokens_day),
150
+ total_requests=self.total_requests,
151
+ total_tokens=self.total_tokens
152
+ )
153
+
154
+
155
+ @dataclass
156
+ class KeyUsage:
157
+ """Represents an API Key and holds multiple UsageBuckets (one per model)"""
158
+ api_key: str
159
+ strategy: RateLimitStrategy
160
+ params: Dict[str, Any] = field(default_factory=dict)
161
+ buckets: Dict[str, UsageBucket] = field(default_factory=lambda: defaultdict(UsageBucket))
162
+ global_bucket: UsageBucket = field(default_factory=UsageBucket)
163
+ last_429: float = 0.0
164
+ dead: bool = False
165
+
166
+ def get_client_params(self) -> Dict[str, Any]:
167
+ """Returns all params for client instantiation."""
168
+ if self.params:
169
+ return dict(self.params)
170
+ return {"api_key": self.api_key}
171
+
172
+ def record_usage(self, model_id: str, tokens: int, timestamp: float = None):
173
+ ts = timestamp if timestamp else time.time()
174
+ self.buckets[model_id].add(tokens, ts)
175
+ if self.strategy == RateLimitStrategy.GLOBAL:
176
+ self.global_bucket.add(tokens, ts)
177
+
178
+ def can_use_model(self, model_id: str, limits: RateLimits, estimated_tokens: int = 1000) -> bool:
179
+ """Check limits based on the provider's strategy"""
180
+ if self.strategy == RateLimitStrategy.GLOBAL:
181
+ return self.global_bucket.check_limits(limits, estimated_tokens)
182
+ else: # Per-Model Limits
183
+ return self.buckets[model_id].check_limits(limits, estimated_tokens)
184
+
185
+ def get_total_snapshot(self) -> UsageSnapshot:
186
+ if self.strategy == RateLimitStrategy.GLOBAL:
187
+ return self.global_bucket.get_snapshot()
188
+ total = UsageSnapshot()
189
+ for b in self.buckets.values():
190
+ total = total + b.get_snapshot()
191
+ return total
192
+
193
+ def reserve(self, model_id: str, tokens: int):
194
+ self.buckets[model_id].reserve(tokens)
195
+ if self.strategy == RateLimitStrategy.GLOBAL:
196
+ self.global_bucket.reserve(tokens)
197
+
198
+ def commit(self, model_id: str, actual_tokens: int, reserved_tokens: int, timestamp: float = None):
199
+ ts = timestamp if timestamp else time.time()
200
+ self.buckets[model_id].commit(actual_tokens, reserved_tokens, ts)
201
+ if self.strategy == RateLimitStrategy.GLOBAL:
202
+ self.global_bucket.commit(actual_tokens, reserved_tokens, ts)
203
+
204
+
205
+ def is_cooling_down(self, cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS) -> bool:
206
+ """Returns True if the key is still in its cooldown penalty period, or dead."""
207
+ if self.dead:
208
+ return True
209
+ if self.last_429 == 0:
210
+ return False
211
+ return (time.time() - self.last_429) < cooldown_seconds
212
+
213
+ def trigger_cooldown(self):
214
+ """Mark this key as rate-limited."""
215
+ self.last_429 = time.time()
216
+
217
+ def mark_dead(self):
218
+ """
219
+ Permanently exclude this key from rotation for the lifetime of this process.
220
+
221
+ Used for errors a short cooldown can't fix - e.g. HTTP 402 payment_required
222
+ when a free-trial key's credits/quota are exhausted. Unlike trigger_cooldown(),
223
+ this never expires; the key stays dead until the process restarts.
224
+ """
225
+ self.dead = True
@@ -0,0 +1,5 @@
1
+ from enum import Enum
2
+
3
+ class RateLimitStrategy(Enum):
4
+ PER_MODEL = "per_model" # Cerebras, Groq, Gemini
5
+ GLOBAL = "global" # OpenRouter (Shared limits across all models)