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.
- keycycle/__init__.py +48 -0
- keycycle/adapters/__init__.py +22 -0
- keycycle/adapters/generic_adapter.py +663 -0
- keycycle/adapters/openai_adapter.py +381 -0
- keycycle/config/__init__.py +25 -0
- keycycle/config/constants.py +35 -0
- keycycle/config/dataclasses.py +225 -0
- keycycle/config/enums.py +5 -0
- keycycle/config/loader.py +54 -0
- keycycle/config/log_config.py +53 -0
- keycycle/config/models/cerebras.yaml +36 -0
- keycycle/config/models/cohere.yaml +14 -0
- keycycle/config/models/gemini.yaml +69 -0
- keycycle/config/models/groq.yaml +161 -0
- keycycle/config/models/mistral.yaml +13 -0
- keycycle/config/models/moonshot.yaml +59 -0
- keycycle/config/models/openrouter.yaml +4 -0
- keycycle/config/models/openrouter_models.yaml +97 -0
- keycycle/config/models.py +53 -0
- keycycle/core/__init__.py +40 -0
- keycycle/core/backoff.py +79 -0
- keycycle/core/exceptions.py +94 -0
- keycycle/core/utils.py +480 -0
- keycycle/key_rotation/__init__.py +7 -0
- keycycle/key_rotation/rotating_mixin.py +392 -0
- keycycle/key_rotation/rotation_manager.py +231 -0
- keycycle/legacy_multi_provider_wrapper.py +734 -0
- keycycle/multi_client_wrapper.py +372 -0
- keycycle/py.typed +0 -0
- keycycle/usage/__init__.py +6 -0
- keycycle/usage/db_logic.py +98 -0
- keycycle/usage/usage_logger.py +89 -0
- keycycle/utils.py +27 -0
- keycycle-0.3.2.dist-info/METADATA +44 -0
- keycycle-0.3.2.dist-info/RECORD +37 -0
- keycycle-0.3.2.dist-info/WHEEL +5 -0
- keycycle-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -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
|