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,392 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import AsyncIterator, Iterator, Optional, Union, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ..config.dataclasses import KeyUsage
|
|
8
|
+
from ..config.log_config import default_logger
|
|
9
|
+
from ..config.constants import (
|
|
10
|
+
TEMP_RATE_LIMIT_MAX_RETRIES,
|
|
11
|
+
TEMP_RATE_LIMIT_INITIAL_DELAY,
|
|
12
|
+
TEMP_RATE_LIMIT_MAX_DELAY,
|
|
13
|
+
TEMP_RATE_LIMIT_MULTIPLIER,
|
|
14
|
+
)
|
|
15
|
+
from ..core.utils import (
|
|
16
|
+
is_rate_limit_error,
|
|
17
|
+
is_temporary_rate_limit_error,
|
|
18
|
+
is_payment_required_error,
|
|
19
|
+
get_key_suffix,
|
|
20
|
+
)
|
|
21
|
+
from ..core.backoff import ExponentialBackoff, BackoffConfig
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from agno.models.response import ModelResponse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _UsageResponse:
|
|
27
|
+
"""Minimal response holder for usage recording when agno is not available."""
|
|
28
|
+
__slots__ = ('response_usage',)
|
|
29
|
+
|
|
30
|
+
def __init__(self, usage=None):
|
|
31
|
+
self.response_usage = usage
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RotatingCredentialsMixin:
|
|
35
|
+
"""
|
|
36
|
+
Mixin that handles key rotation, 429 detection, and 30s cooldown triggers.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*args,
|
|
42
|
+
model_id: str,
|
|
43
|
+
wrapper=None,
|
|
44
|
+
rotating_wait=True,
|
|
45
|
+
rotating_timeout=10.0,
|
|
46
|
+
rotating_estimated_tokens=1000,
|
|
47
|
+
rotating_max_retries=5,
|
|
48
|
+
rotating_fixed_key_id: Union[int, str] = None,
|
|
49
|
+
logger: Optional[logging.Logger] = None,
|
|
50
|
+
**kwargs):
|
|
51
|
+
"""
|
|
52
|
+
Initializes rotation parameters and patches the model if necessary.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
wrapper: The MultiProviderWrapper instance managing keys.
|
|
56
|
+
rotating_wait: Whether to wait for a key if none are available.
|
|
57
|
+
rotating_timeout: Max time to wait for a key.
|
|
58
|
+
rotating_estimated_tokens: Default token estimate for rate limiting.
|
|
59
|
+
rotating_max_retries: Number of retries on 429 errors.
|
|
60
|
+
"""
|
|
61
|
+
self.logger = logger or default_logger
|
|
62
|
+
self.wrapper = wrapper
|
|
63
|
+
self.model_id = model_id
|
|
64
|
+
self._rotating_wait = rotating_wait
|
|
65
|
+
self._rotating_timeout = rotating_timeout
|
|
66
|
+
self._estimated_tokens = rotating_estimated_tokens
|
|
67
|
+
self._max_retries = rotating_max_retries
|
|
68
|
+
self._fixed_key_id = rotating_fixed_key_id
|
|
69
|
+
|
|
70
|
+
super().__init__(*args, **kwargs)
|
|
71
|
+
|
|
72
|
+
def __init_subclass__(cls, **kwargs):
|
|
73
|
+
"""
|
|
74
|
+
Hook to automatically copy function signatures and docstrings
|
|
75
|
+
from the parent class to this mixin's wrapper methods.
|
|
76
|
+
"""
|
|
77
|
+
super().__init_subclass__(**kwargs)
|
|
78
|
+
methods_to_sync = ['invoke', 'ainvoke', 'invoke_stream', 'ainvoke_stream']
|
|
79
|
+
for method_name in methods_to_sync:
|
|
80
|
+
if not hasattr(cls, method_name):
|
|
81
|
+
continue
|
|
82
|
+
target_method = None
|
|
83
|
+
for parent in cls.mro():
|
|
84
|
+
if parent is cls or parent is RotatingCredentialsMixin:
|
|
85
|
+
continue
|
|
86
|
+
# Check if the method is defined in this specific parent's __dict__
|
|
87
|
+
if hasattr(parent, method_name) and method_name in parent.__dict__:
|
|
88
|
+
target_method = getattr(parent, method_name)
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
if target_method:
|
|
92
|
+
mixin_implementation = getattr(cls, method_name)
|
|
93
|
+
wrapped_method = functools.wraps(target_method)(mixin_implementation)
|
|
94
|
+
|
|
95
|
+
if hasattr(target_method, '__signature__'):
|
|
96
|
+
wrapped_method.__signature__ = target_method.__signature__
|
|
97
|
+
setattr(cls, method_name, wrapped_method)
|
|
98
|
+
|
|
99
|
+
def _rotate_credentials(self) -> KeyUsage:
|
|
100
|
+
key_usage: KeyUsage = self.wrapper.get_key_usage(
|
|
101
|
+
model_id=self.model_id,
|
|
102
|
+
estimated_tokens=self._estimated_tokens,
|
|
103
|
+
wait=self._rotating_wait,
|
|
104
|
+
timeout=self._rotating_timeout,
|
|
105
|
+
key_id=self._fixed_key_id
|
|
106
|
+
)
|
|
107
|
+
self.api_key = key_usage.api_key
|
|
108
|
+
|
|
109
|
+
if hasattr(self, "client"): self.client = None
|
|
110
|
+
if hasattr(self, "async_client"): self.async_client = None
|
|
111
|
+
if hasattr(self, "gemini_client"): self.gemini_client = None
|
|
112
|
+
|
|
113
|
+
return key_usage
|
|
114
|
+
|
|
115
|
+
def _get_retry_limit(self) -> int:
|
|
116
|
+
return min(self._max_retries, len(self.wrapper.manager.keys) - 1)
|
|
117
|
+
|
|
118
|
+
def _record_usage(self, key_obj: KeyUsage, response: Optional["ModelResponse"]):
|
|
119
|
+
"""
|
|
120
|
+
Extracts usage from the response and reports it to the manager.
|
|
121
|
+
Falls back to estimated_tokens if response is None or usage is missing.
|
|
122
|
+
"""
|
|
123
|
+
if not self.wrapper:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
actual_tokens = 0
|
|
127
|
+
|
|
128
|
+
if response and response.response_usage:
|
|
129
|
+
actual_tokens = response.response_usage.total_tokens
|
|
130
|
+
|
|
131
|
+
# Fallback: If no usage found (or streaming), use the estimate
|
|
132
|
+
if actual_tokens == 0:
|
|
133
|
+
actual_tokens = self._estimated_tokens
|
|
134
|
+
|
|
135
|
+
self.wrapper.manager.record_usage(
|
|
136
|
+
key_obj=key_obj,
|
|
137
|
+
model_id=self.model_id,
|
|
138
|
+
actual_tokens=actual_tokens,
|
|
139
|
+
estimated_tokens=self._estimated_tokens
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _create_temp_backoff(self) -> ExponentialBackoff:
|
|
143
|
+
"""Create a backoff instance for temporary rate limit retries."""
|
|
144
|
+
return ExponentialBackoff(BackoffConfig(
|
|
145
|
+
initial_interval=TEMP_RATE_LIMIT_INITIAL_DELAY,
|
|
146
|
+
max_interval=TEMP_RATE_LIMIT_MAX_DELAY,
|
|
147
|
+
multiplier=TEMP_RATE_LIMIT_MULTIPLIER,
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
def invoke(self, *args, **kwargs) -> "ModelResponse":
|
|
151
|
+
limit = self._get_retry_limit()
|
|
152
|
+
|
|
153
|
+
for attempt in range(limit + 1):
|
|
154
|
+
key_usage = self._rotate_credentials()
|
|
155
|
+
temp_backoff = self._create_temp_backoff()
|
|
156
|
+
|
|
157
|
+
for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
|
|
158
|
+
try:
|
|
159
|
+
response = super().invoke(*args, **kwargs)
|
|
160
|
+
self._record_usage(key_usage, response)
|
|
161
|
+
return response
|
|
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
|
+
self.logger.info(
|
|
167
|
+
"Temporary rate limit on key %s [%s]. Waiting %.1fs (%d/%d).",
|
|
168
|
+
get_key_suffix(self.api_key), self.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 < limit:
|
|
177
|
+
self.logger.warning(
|
|
178
|
+
"402 Hit on key %s (Sync) [%s]. Marking key dead and rotating (%d/%d).",
|
|
179
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
180
|
+
)
|
|
181
|
+
key_usage.mark_dead()
|
|
182
|
+
self.wrapper.manager.force_rotate_index()
|
|
183
|
+
break # Break inner loop, continue outer with new key
|
|
184
|
+
|
|
185
|
+
# Hard rate limit - rotate key
|
|
186
|
+
if is_rate_limit_error(e) and attempt < limit:
|
|
187
|
+
self.logger.warning(
|
|
188
|
+
"429 Hit on key %s (Sync) [%s]. Rotating and retrying (%d/%d).",
|
|
189
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
190
|
+
)
|
|
191
|
+
key_usage.trigger_cooldown()
|
|
192
|
+
self.wrapper.manager.force_rotate_index()
|
|
193
|
+
break # Break inner loop, continue outer with new key
|
|
194
|
+
raise
|
|
195
|
+
else:
|
|
196
|
+
# Temp retries exhausted, move to next key
|
|
197
|
+
if attempt < limit:
|
|
198
|
+
self.logger.warning(
|
|
199
|
+
"Temp rate limit retries exhausted for key %s. Rotating.",
|
|
200
|
+
get_key_suffix(self.api_key)
|
|
201
|
+
)
|
|
202
|
+
key_usage.trigger_cooldown()
|
|
203
|
+
self.wrapper.manager.force_rotate_index()
|
|
204
|
+
continue
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
async def ainvoke(self, *args, **kwargs) -> "ModelResponse":
|
|
208
|
+
limit = self._get_retry_limit()
|
|
209
|
+
|
|
210
|
+
for attempt in range(limit + 1):
|
|
211
|
+
key_usage = self._rotate_credentials()
|
|
212
|
+
temp_backoff = self._create_temp_backoff()
|
|
213
|
+
|
|
214
|
+
for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
|
|
215
|
+
try:
|
|
216
|
+
response = await super().ainvoke(*args, **kwargs)
|
|
217
|
+
self._record_usage(key_usage, response)
|
|
218
|
+
return response
|
|
219
|
+
except Exception as e:
|
|
220
|
+
# Check for temporary rate limit first - retry with SAME key
|
|
221
|
+
if is_temporary_rate_limit_error(e) and temp_attempt < TEMP_RATE_LIMIT_MAX_RETRIES:
|
|
222
|
+
delay = temp_backoff.get_next_interval()
|
|
223
|
+
self.logger.info(
|
|
224
|
+
"Temporary rate limit on key %s [%s]. Waiting %.1fs (%d/%d).",
|
|
225
|
+
get_key_suffix(self.api_key), self.model_id, delay,
|
|
226
|
+
temp_attempt + 1, TEMP_RATE_LIMIT_MAX_RETRIES
|
|
227
|
+
)
|
|
228
|
+
await asyncio.sleep(delay)
|
|
229
|
+
continue # Retry with SAME key
|
|
230
|
+
|
|
231
|
+
# Payment required (quota/credits exhausted) - key is dead
|
|
232
|
+
# for this process, rotate to next key
|
|
233
|
+
if is_payment_required_error(e) and attempt < limit:
|
|
234
|
+
self.logger.warning(
|
|
235
|
+
"402 Hit on key %s (Async) [%s]. Marking key dead and rotating (%d/%d).",
|
|
236
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
237
|
+
)
|
|
238
|
+
key_usage.mark_dead()
|
|
239
|
+
self.wrapper.manager.force_rotate_index()
|
|
240
|
+
break # Break inner loop, continue outer with new key
|
|
241
|
+
|
|
242
|
+
# Hard rate limit - rotate key
|
|
243
|
+
if is_rate_limit_error(e) and attempt < limit:
|
|
244
|
+
self.logger.warning(
|
|
245
|
+
"429 Hit on key %s (Async) [%s]. Rotating and retrying (%d/%d).",
|
|
246
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
247
|
+
)
|
|
248
|
+
key_usage.trigger_cooldown()
|
|
249
|
+
self.wrapper.manager.force_rotate_index()
|
|
250
|
+
break # Break inner loop, continue outer with new key
|
|
251
|
+
raise
|
|
252
|
+
else:
|
|
253
|
+
# Temp retries exhausted, move to next key
|
|
254
|
+
if attempt < limit:
|
|
255
|
+
self.logger.warning(
|
|
256
|
+
"Temp rate limit retries exhausted for key %s. Rotating.",
|
|
257
|
+
get_key_suffix(self.api_key)
|
|
258
|
+
)
|
|
259
|
+
key_usage.trigger_cooldown()
|
|
260
|
+
self.wrapper.manager.force_rotate_index()
|
|
261
|
+
continue
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
def invoke_stream(self, *args, **kwargs) -> Iterator["ModelResponse"]:
|
|
265
|
+
limit = self._get_retry_limit()
|
|
266
|
+
|
|
267
|
+
for attempt in range(limit + 1):
|
|
268
|
+
key_usage = self._rotate_credentials()
|
|
269
|
+
temp_backoff = self._create_temp_backoff()
|
|
270
|
+
|
|
271
|
+
for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
|
|
272
|
+
chunks_received = False
|
|
273
|
+
try:
|
|
274
|
+
stream = super().invoke_stream(*args, **kwargs)
|
|
275
|
+
final_usage = None
|
|
276
|
+
for chunk in stream:
|
|
277
|
+
chunks_received = True
|
|
278
|
+
if chunk.response_usage:
|
|
279
|
+
final_usage = chunk.response_usage
|
|
280
|
+
yield chunk
|
|
281
|
+
if final_usage:
|
|
282
|
+
dummy_response = _UsageResponse(final_usage)
|
|
283
|
+
self._record_usage(key_usage, dummy_response)
|
|
284
|
+
else:
|
|
285
|
+
# If the stream didn't return usage data, fallback to estimation
|
|
286
|
+
self._record_usage(key_usage, None)
|
|
287
|
+
|
|
288
|
+
return
|
|
289
|
+
except Exception as e:
|
|
290
|
+
# Only retry temp limits if NO chunks received yet
|
|
291
|
+
if (is_temporary_rate_limit_error(e)
|
|
292
|
+
and temp_attempt < TEMP_RATE_LIMIT_MAX_RETRIES
|
|
293
|
+
and not chunks_received):
|
|
294
|
+
delay = temp_backoff.get_next_interval()
|
|
295
|
+
self.logger.info(
|
|
296
|
+
"Temporary rate limit on key %s (Stream) [%s]. Waiting %.1fs (%d/%d).",
|
|
297
|
+
get_key_suffix(self.api_key), self.model_id, delay,
|
|
298
|
+
temp_attempt + 1, TEMP_RATE_LIMIT_MAX_RETRIES
|
|
299
|
+
)
|
|
300
|
+
time.sleep(delay)
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
if is_payment_required_error(e) and attempt < limit:
|
|
304
|
+
self.logger.warning(
|
|
305
|
+
"402 Hit on key %s (Sync Stream) [%s]. Marking key dead and rotating (%d/%d).",
|
|
306
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
307
|
+
)
|
|
308
|
+
key_usage.mark_dead()
|
|
309
|
+
self.wrapper.manager.force_rotate_index()
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
if is_rate_limit_error(e) and attempt < limit:
|
|
313
|
+
self.logger.warning(
|
|
314
|
+
"429 Hit on key %s (Sync Stream) [%s]. Rotating and retrying (%d/%d).",
|
|
315
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
316
|
+
)
|
|
317
|
+
key_usage.trigger_cooldown()
|
|
318
|
+
self.wrapper.manager.force_rotate_index()
|
|
319
|
+
break
|
|
320
|
+
raise
|
|
321
|
+
else:
|
|
322
|
+
if attempt < limit:
|
|
323
|
+
key_usage.trigger_cooldown()
|
|
324
|
+
self.wrapper.manager.force_rotate_index()
|
|
325
|
+
continue
|
|
326
|
+
raise
|
|
327
|
+
|
|
328
|
+
async def ainvoke_stream(self, *args, **kwargs) -> AsyncIterator["ModelResponse"]:
|
|
329
|
+
limit = self._get_retry_limit()
|
|
330
|
+
|
|
331
|
+
for attempt in range(limit + 1):
|
|
332
|
+
key_usage = self._rotate_credentials()
|
|
333
|
+
temp_backoff = self._create_temp_backoff()
|
|
334
|
+
|
|
335
|
+
for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
|
|
336
|
+
chunks_received = False
|
|
337
|
+
try:
|
|
338
|
+
stream = super().ainvoke_stream(*args, **kwargs)
|
|
339
|
+
|
|
340
|
+
final_usage = None
|
|
341
|
+
async for chunk in stream:
|
|
342
|
+
chunks_received = True
|
|
343
|
+
if chunk.response_usage:
|
|
344
|
+
final_usage = chunk.response_usage
|
|
345
|
+
yield chunk
|
|
346
|
+
|
|
347
|
+
# Stream completed successfully. Record usage.
|
|
348
|
+
if final_usage:
|
|
349
|
+
dummy_response = _UsageResponse(final_usage)
|
|
350
|
+
self._record_usage(key_usage, dummy_response)
|
|
351
|
+
else:
|
|
352
|
+
self._record_usage(key_usage, None)
|
|
353
|
+
|
|
354
|
+
return
|
|
355
|
+
except Exception as e:
|
|
356
|
+
# Only retry temp limits if NO chunks received yet
|
|
357
|
+
if (is_temporary_rate_limit_error(e)
|
|
358
|
+
and temp_attempt < TEMP_RATE_LIMIT_MAX_RETRIES
|
|
359
|
+
and not chunks_received):
|
|
360
|
+
delay = temp_backoff.get_next_interval()
|
|
361
|
+
self.logger.info(
|
|
362
|
+
"Temporary rate limit on key %s (Async Stream) [%s]. Waiting %.1fs (%d/%d).",
|
|
363
|
+
get_key_suffix(self.api_key), self.model_id, delay,
|
|
364
|
+
temp_attempt + 1, TEMP_RATE_LIMIT_MAX_RETRIES
|
|
365
|
+
)
|
|
366
|
+
await asyncio.sleep(delay)
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
if is_payment_required_error(e) and attempt < limit:
|
|
370
|
+
self.logger.warning(
|
|
371
|
+
"402 Hit on key %s (Async Stream) [%s]. Marking key dead and rotating (%d/%d).",
|
|
372
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
373
|
+
)
|
|
374
|
+
key_usage.mark_dead()
|
|
375
|
+
self.wrapper.manager.force_rotate_index()
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
if is_rate_limit_error(e) and attempt < limit:
|
|
379
|
+
self.logger.warning(
|
|
380
|
+
"429 Hit on key %s (Async Stream) [%s]. Rotating and retrying (%d/%d).",
|
|
381
|
+
get_key_suffix(self.api_key), self.model_id, attempt + 1, limit
|
|
382
|
+
)
|
|
383
|
+
key_usage.trigger_cooldown()
|
|
384
|
+
self.wrapper.manager.force_rotate_index()
|
|
385
|
+
break
|
|
386
|
+
raise
|
|
387
|
+
else:
|
|
388
|
+
if attempt < limit:
|
|
389
|
+
key_usage.trigger_cooldown()
|
|
390
|
+
self.wrapper.manager.force_rotate_index()
|
|
391
|
+
continue
|
|
392
|
+
raise
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import atexit
|
|
3
|
+
import threading
|
|
4
|
+
from threading import Lock, Event
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from ..config.dataclasses import (
|
|
9
|
+
RateLimits, UsageSnapshot,
|
|
10
|
+
GlobalStats, KeySummary,
|
|
11
|
+
KeyDetailedStats, ModelAggregatedStats,
|
|
12
|
+
KeyUsage
|
|
13
|
+
)
|
|
14
|
+
from ..config.enums import RateLimitStrategy
|
|
15
|
+
from ..config.log_config import default_logger
|
|
16
|
+
from ..config.constants import (
|
|
17
|
+
CLEANUP_INTERVAL_SECONDS,
|
|
18
|
+
HISTORY_LOOKBACK_SECONDS,
|
|
19
|
+
DEFAULT_COOLDOWN_SECONDS,
|
|
20
|
+
)
|
|
21
|
+
from ..core.utils import get_key_suffix, KeyEntry, normalize_key_entries
|
|
22
|
+
from ..usage.usage_logger import AsyncUsageLogger
|
|
23
|
+
from ..usage.db_logic import UsageDatabase
|
|
24
|
+
|
|
25
|
+
class RotatingKeyManager:
|
|
26
|
+
"""Manages API key rotation with rate limiting"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_keys: List[KeyEntry],
|
|
31
|
+
provider_name: str,
|
|
32
|
+
strategy: RateLimitStrategy,
|
|
33
|
+
db: UsageDatabase,
|
|
34
|
+
logger: Optional[logging.Logger] = None,
|
|
35
|
+
cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS,
|
|
36
|
+
limit_resolver: Optional[Callable[[str, Optional[str]], RateLimits]] = None,
|
|
37
|
+
api_key_param: str = "api_key",
|
|
38
|
+
):
|
|
39
|
+
self.provider_name = provider_name
|
|
40
|
+
self.logger = logger or default_logger
|
|
41
|
+
self.strategy = strategy
|
|
42
|
+
self.cooldown_seconds = cooldown_seconds
|
|
43
|
+
self.limit_resolver = limit_resolver
|
|
44
|
+
self.api_key_param = api_key_param
|
|
45
|
+
|
|
46
|
+
# Normalize key entries and create KeyUsage objects with params
|
|
47
|
+
normalized = normalize_key_entries(api_keys, api_key_param)
|
|
48
|
+
self.keys = [
|
|
49
|
+
KeyUsage(api_key=primary, strategy=strategy, params=params)
|
|
50
|
+
for primary, params in normalized
|
|
51
|
+
]
|
|
52
|
+
self.current_index = 0
|
|
53
|
+
self.lock = Lock()
|
|
54
|
+
|
|
55
|
+
self.db = db
|
|
56
|
+
self.usage_logger = AsyncUsageLogger(self.db)
|
|
57
|
+
self._hydrate()
|
|
58
|
+
|
|
59
|
+
self._stop_event = Event()
|
|
60
|
+
self._start_cleanup()
|
|
61
|
+
atexit.register(self.stop)
|
|
62
|
+
|
|
63
|
+
self.logger.info("Initialized %d keys for provider %s.", len(self.keys), provider_name)
|
|
64
|
+
|
|
65
|
+
def force_rotate_index(self) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Force the internal pointer to increment.
|
|
68
|
+
Useful when a key hits a 429 despite local checks passing.
|
|
69
|
+
"""
|
|
70
|
+
with self.lock:
|
|
71
|
+
self.current_index = (self.current_index + 1) % len(self.keys)
|
|
72
|
+
|
|
73
|
+
def _hydrate(self) -> None:
|
|
74
|
+
"""Load historical usage from database to restore state."""
|
|
75
|
+
self.logger.debug("Loading history for provider %s.", self.provider_name)
|
|
76
|
+
all_history = self.db.load_provider_history(self.provider_name, HISTORY_LOOKBACK_SECONDS)
|
|
77
|
+
if not all_history:
|
|
78
|
+
self.logger.info("No history found in DB for %s.", self.provider_name)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
key_map = {get_key_suffix(k.api_key): k for k in self.keys}
|
|
82
|
+
count = 0
|
|
83
|
+
for row in all_history:
|
|
84
|
+
suffix, model_id, ts, tokens = row
|
|
85
|
+
|
|
86
|
+
if suffix in key_map:
|
|
87
|
+
key_map[suffix].record_usage(model_id, tokens=tokens, timestamp=ts)
|
|
88
|
+
count += 1
|
|
89
|
+
self.logger.info("Hydrated %d records for %s.",
|
|
90
|
+
count, self.provider_name)
|
|
91
|
+
|
|
92
|
+
def _cleanup_loop(self) -> None:
|
|
93
|
+
"""Periodically clean deques to prevent memory bloat."""
|
|
94
|
+
while not self._stop_event.is_set():
|
|
95
|
+
try:
|
|
96
|
+
with self.lock:
|
|
97
|
+
for key in self.keys:
|
|
98
|
+
for bucket in key.buckets.values():
|
|
99
|
+
bucket.clean()
|
|
100
|
+
if self.strategy == RateLimitStrategy.GLOBAL:
|
|
101
|
+
key.global_bucket.clean()
|
|
102
|
+
except Exception as e:
|
|
103
|
+
self.logger.error("Cleanup loop error: %s", e, exc_info=True)
|
|
104
|
+
time.sleep(CLEANUP_INTERVAL_SECONDS)
|
|
105
|
+
|
|
106
|
+
def _start_cleanup(self) -> None:
|
|
107
|
+
self._thread = threading.Thread(target=self._cleanup_loop, daemon=True)
|
|
108
|
+
self._thread.start()
|
|
109
|
+
|
|
110
|
+
def stop(self) -> None:
|
|
111
|
+
"""Stop the cleanup thread and flush logs."""
|
|
112
|
+
self._stop_event.set()
|
|
113
|
+
self.usage_logger.stop() # Flush logs
|
|
114
|
+
if self._thread.is_alive():
|
|
115
|
+
self._thread.join(timeout=10)
|
|
116
|
+
|
|
117
|
+
def get_key(self, model_id: str, default_limits: RateLimits, estimated_tokens: int = 1000) -> Optional[KeyUsage]:
|
|
118
|
+
"""
|
|
119
|
+
Get an available API key that can handle the request.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
model_id: The model identifier for rate limit lookup
|
|
123
|
+
default_limits: Default limits to use if no per-key override exists
|
|
124
|
+
estimated_tokens: Estimated token usage for this request
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
KeyUsage object if a key is available, None otherwise
|
|
128
|
+
"""
|
|
129
|
+
with self.lock:
|
|
130
|
+
for offset in range(len(self.keys)):
|
|
131
|
+
idx = (self.current_index + offset) % len(self.keys)
|
|
132
|
+
key: KeyUsage = self.keys[idx]
|
|
133
|
+
|
|
134
|
+
if key.is_cooling_down(self.cooldown_seconds):
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Resolve limits for this specific key (supports per-key overrides)
|
|
138
|
+
if self.limit_resolver:
|
|
139
|
+
key_suffix = get_key_suffix(key.api_key)
|
|
140
|
+
limits = self.limit_resolver(model_id, key_suffix)
|
|
141
|
+
else:
|
|
142
|
+
limits = default_limits
|
|
143
|
+
|
|
144
|
+
if key.can_use_model(model_id, limits, estimated_tokens):
|
|
145
|
+
key.reserve(model_id, estimated_tokens)
|
|
146
|
+
self.current_index = idx
|
|
147
|
+
return key
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def get_specific_key(self, identifier: Union[int, str], model_id: str, estimated_tokens: int = 1000) -> Optional[KeyUsage]:
|
|
151
|
+
"""
|
|
152
|
+
Get a specific key by index or identifier.
|
|
153
|
+
Note: This does NOT check rate limits implicitly to allow 'hard' retrieval,
|
|
154
|
+
but it DOES reserve the estimated tokens to keep tracking accurate.
|
|
155
|
+
"""
|
|
156
|
+
with self.lock:
|
|
157
|
+
key, _ = self._find_key(identifier)
|
|
158
|
+
if key:
|
|
159
|
+
key.reserve(model_id, estimated_tokens)
|
|
160
|
+
return key
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def record_usage(
|
|
164
|
+
self, key_obj: KeyUsage, model_id: str, actual_tokens: int, estimated_tokens: int = 1000
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Record usage for a specific API key."""
|
|
167
|
+
with self.lock:
|
|
168
|
+
key_obj.commit(model_id, actual_tokens, estimated_tokens)
|
|
169
|
+
self.usage_logger.log(self.provider_name, model_id, key_obj.api_key, actual_tokens)
|
|
170
|
+
|
|
171
|
+
# --- STATS HELPERS ---
|
|
172
|
+
|
|
173
|
+
def _find_key(self, identifier: Union[int, str]) -> Tuple[Optional[KeyUsage], int]:
|
|
174
|
+
"""Locate key by index (int) or suffix/full-key (str)."""
|
|
175
|
+
if isinstance(identifier, int):
|
|
176
|
+
if 0 <= identifier < len(self.keys):
|
|
177
|
+
return self.keys[identifier], identifier
|
|
178
|
+
elif isinstance(identifier, str):
|
|
179
|
+
for i, k in enumerate(self.keys):
|
|
180
|
+
if k.api_key == identifier or k.api_key.endswith(identifier):
|
|
181
|
+
return k, i
|
|
182
|
+
return None, -1
|
|
183
|
+
|
|
184
|
+
def get_global_stats(self) -> GlobalStats:
|
|
185
|
+
"""Aggregates usage across all keys and models."""
|
|
186
|
+
total = UsageSnapshot()
|
|
187
|
+
keys_summary = []
|
|
188
|
+
with self.lock:
|
|
189
|
+
for i, key in enumerate(self.keys):
|
|
190
|
+
snap = key.get_total_snapshot()
|
|
191
|
+
total = total + snap
|
|
192
|
+
suffix = get_key_suffix(key.api_key)
|
|
193
|
+
keys_summary.append(KeySummary(index=i, suffix=suffix, snapshot=snap))
|
|
194
|
+
return GlobalStats(total=total, keys=keys_summary)
|
|
195
|
+
|
|
196
|
+
def get_key_stats(self, identifier: Union[int, str]) -> Optional[KeyDetailedStats]:
|
|
197
|
+
"""Stats for a specific key, including per-model breakdown."""
|
|
198
|
+
with self.lock:
|
|
199
|
+
key, idx = self._find_key(identifier)
|
|
200
|
+
if not key:
|
|
201
|
+
return None
|
|
202
|
+
total_snap = key.get_total_snapshot()
|
|
203
|
+
breakdown = {}
|
|
204
|
+
for model, bucket in key.buckets.items():
|
|
205
|
+
breakdown[model] = bucket.get_snapshot()
|
|
206
|
+
suffix = get_key_suffix(key.api_key)
|
|
207
|
+
return KeyDetailedStats(index=idx, suffix=suffix, total=total_snap, breakdown=breakdown)
|
|
208
|
+
|
|
209
|
+
def get_model_stats(self, model_id: str) -> ModelAggregatedStats:
|
|
210
|
+
"""Aggregates stats for ONE model across ALL keys."""
|
|
211
|
+
total = UsageSnapshot()
|
|
212
|
+
contributing_keys = []
|
|
213
|
+
with self.lock:
|
|
214
|
+
for i, key in enumerate(self.keys):
|
|
215
|
+
if model_id in key.buckets:
|
|
216
|
+
snap = key.buckets[model_id].get_snapshot()
|
|
217
|
+
total = total + snap
|
|
218
|
+
suffix = get_key_suffix(key.api_key)
|
|
219
|
+
contributing_keys.append(KeySummary(index=i, suffix=suffix, snapshot=snap))
|
|
220
|
+
return ModelAggregatedStats(model_id=model_id, total=total, keys=contributing_keys)
|
|
221
|
+
|
|
222
|
+
def get_granular_stats(self, identifier: Union[int, str], model_id: str) -> Optional[KeySummary]:
|
|
223
|
+
"""Specific Key + Specific Model."""
|
|
224
|
+
with self.lock:
|
|
225
|
+
key, idx = self._find_key(identifier)
|
|
226
|
+
if not key:
|
|
227
|
+
return None
|
|
228
|
+
suffix = get_key_suffix(key.api_key)
|
|
229
|
+
snap = key.buckets[model_id].get_snapshot() if model_id in key.buckets else UsageSnapshot()
|
|
230
|
+
return KeySummary(index=idx, suffix=suffix, snapshot=snap)
|
|
231
|
+
|