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,663 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic rotating client adapter for any Python client that accepts api_key= in its constructor.
|
|
3
|
+
|
|
4
|
+
This module provides a drop-in replacement wrapper that automatically rotates API keys
|
|
5
|
+
on rate limit errors for any compatible client library (Anthropic, TwelveLabs, Cohere, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import inspect
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
AsyncGenerator,
|
|
16
|
+
Callable,
|
|
17
|
+
FrozenSet,
|
|
18
|
+
Generator,
|
|
19
|
+
Generic,
|
|
20
|
+
List,
|
|
21
|
+
Optional,
|
|
22
|
+
Type,
|
|
23
|
+
TypeVar,
|
|
24
|
+
Union,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ..config.dataclasses import KeyUsage, RateLimits
|
|
28
|
+
from ..config.constants import (
|
|
29
|
+
TEMP_RATE_LIMIT_MAX_RETRIES,
|
|
30
|
+
TEMP_RATE_LIMIT_INITIAL_DELAY,
|
|
31
|
+
TEMP_RATE_LIMIT_MAX_DELAY,
|
|
32
|
+
TEMP_RATE_LIMIT_MULTIPLIER,
|
|
33
|
+
KEY_ROTATION_DELAY_SECONDS,
|
|
34
|
+
)
|
|
35
|
+
from ..core.utils import (
|
|
36
|
+
is_rate_limit_error,
|
|
37
|
+
is_temporary_rate_limit_error,
|
|
38
|
+
is_payment_required_error,
|
|
39
|
+
get_key_suffix,
|
|
40
|
+
)
|
|
41
|
+
from ..core.backoff import ExponentialBackoff, BackoffConfig
|
|
42
|
+
from ..key_rotation.rotation_manager import RotatingKeyManager
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
T = TypeVar("T")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def detect_async_client(client_class: Type) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Auto-detect if a client class is async.
|
|
52
|
+
|
|
53
|
+
Checks for:
|
|
54
|
+
- 'Async' in class name
|
|
55
|
+
- __aenter__, __aexit__, aclose methods
|
|
56
|
+
- Public methods that are coroutine functions
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
client_class: The client class to check
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if the client appears to be async, False otherwise
|
|
63
|
+
"""
|
|
64
|
+
# Check class name for 'Async'
|
|
65
|
+
if "Async" in client_class.__name__:
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
# Check for async context manager methods
|
|
69
|
+
if hasattr(client_class, "__aenter__") or hasattr(client_class, "__aexit__"):
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
# Check for aclose method (common in async clients)
|
|
73
|
+
if hasattr(client_class, "aclose"):
|
|
74
|
+
aclose = getattr(client_class, "aclose")
|
|
75
|
+
if asyncio.iscoroutinefunction(aclose):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# Check if any public methods are coroutine functions
|
|
79
|
+
for name in dir(client_class):
|
|
80
|
+
if name.startswith("_"):
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
attr = getattr(client_class, name)
|
|
84
|
+
if asyncio.iscoroutinefunction(attr):
|
|
85
|
+
return True
|
|
86
|
+
except (AttributeError, TypeError):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def default_usage_extractor(response: Any) -> int:
|
|
93
|
+
"""
|
|
94
|
+
Default usage extractor that handles multiple common patterns.
|
|
95
|
+
|
|
96
|
+
Supports:
|
|
97
|
+
- response.usage.total_tokens (OpenAI)
|
|
98
|
+
- response.usage.input_tokens + output_tokens (Anthropic)
|
|
99
|
+
- response.meta.billed_units (Cohere)
|
|
100
|
+
- response['usage']['total_tokens'] (dict-style)
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
response: The API response object
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Total tokens used, or 0 if unable to extract
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
# OpenAI style: response.usage.total_tokens
|
|
110
|
+
if hasattr(response, "usage") and response.usage:
|
|
111
|
+
usage = response.usage
|
|
112
|
+
if hasattr(usage, "total_tokens") and usage.total_tokens is not None:
|
|
113
|
+
return usage.total_tokens
|
|
114
|
+
# Anthropic style: input_tokens + output_tokens
|
|
115
|
+
if hasattr(usage, "input_tokens") and hasattr(usage, "output_tokens"):
|
|
116
|
+
input_t = usage.input_tokens or 0
|
|
117
|
+
output_t = usage.output_tokens or 0
|
|
118
|
+
return input_t + output_t
|
|
119
|
+
except (AttributeError, TypeError):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
# Cohere style: response.meta.billed_units
|
|
123
|
+
try:
|
|
124
|
+
if hasattr(response, "meta") and response.meta:
|
|
125
|
+
meta = response.meta
|
|
126
|
+
if hasattr(meta, "billed_units") and meta.billed_units:
|
|
127
|
+
units = meta.billed_units
|
|
128
|
+
total = 0
|
|
129
|
+
if hasattr(units, "input_tokens"):
|
|
130
|
+
total += units.input_tokens or 0
|
|
131
|
+
if hasattr(units, "output_tokens"):
|
|
132
|
+
total += units.output_tokens or 0
|
|
133
|
+
return total
|
|
134
|
+
except (AttributeError, TypeError):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# Dict-style: response['usage']['total_tokens']
|
|
138
|
+
try:
|
|
139
|
+
if isinstance(response, dict):
|
|
140
|
+
usage = response.get("usage", {})
|
|
141
|
+
if isinstance(usage, dict):
|
|
142
|
+
total = usage.get("total_tokens")
|
|
143
|
+
if total is not None:
|
|
144
|
+
return total
|
|
145
|
+
# Dict with input/output tokens
|
|
146
|
+
input_t = usage.get("input_tokens", 0) or 0
|
|
147
|
+
output_t = usage.get("output_tokens", 0) or 0
|
|
148
|
+
if input_t or output_t:
|
|
149
|
+
return input_t + output_t
|
|
150
|
+
except (KeyError, TypeError):
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_valid_constructor_kwargs(client_class: Type) -> Optional[FrozenSet[str]]:
|
|
157
|
+
"""
|
|
158
|
+
Inspect client class to determine valid constructor kwargs.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
FrozenSet of valid kwarg names, or None if client accepts **kwargs
|
|
162
|
+
(meaning introspection can't filter).
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
sig = inspect.signature(client_class.__init__)
|
|
166
|
+
except (ValueError, TypeError):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
params = sig.parameters
|
|
170
|
+
valid_kwargs = set()
|
|
171
|
+
|
|
172
|
+
for name, param in params.items():
|
|
173
|
+
if name == 'self':
|
|
174
|
+
continue
|
|
175
|
+
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
176
|
+
return None # Accepts **kwargs, can't filter
|
|
177
|
+
if param.kind in (
|
|
178
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
179
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
180
|
+
):
|
|
181
|
+
valid_kwargs.add(name)
|
|
182
|
+
|
|
183
|
+
return frozenset(valid_kwargs) if valid_kwargs else None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class GenericClientConfig:
|
|
188
|
+
"""Configuration for a generic rotating client."""
|
|
189
|
+
|
|
190
|
+
client_class: Type
|
|
191
|
+
"""The client class to wrap (e.g., Anthropic, TwelveLabs)"""
|
|
192
|
+
|
|
193
|
+
api_key_param: str = "api_key"
|
|
194
|
+
"""Name of the API key parameter in the client constructor"""
|
|
195
|
+
|
|
196
|
+
is_async: Optional[bool] = None
|
|
197
|
+
"""Whether the client is async. If None, auto-detected."""
|
|
198
|
+
|
|
199
|
+
usage_extractor: Optional[Callable[[Any], int]] = None
|
|
200
|
+
"""Function to extract token usage from responses. Uses default if None."""
|
|
201
|
+
|
|
202
|
+
estimated_tokens: int = 1000
|
|
203
|
+
"""Estimated tokens per request for rate limiting"""
|
|
204
|
+
|
|
205
|
+
max_retries: int = 5
|
|
206
|
+
"""Maximum number of key rotations on rate limit errors"""
|
|
207
|
+
|
|
208
|
+
model_param: str = "model"
|
|
209
|
+
"""Name of the model parameter in API calls"""
|
|
210
|
+
|
|
211
|
+
client_kwargs: dict = field(default_factory=dict)
|
|
212
|
+
"""Additional kwargs to pass to the client constructor"""
|
|
213
|
+
|
|
214
|
+
excluded_kwargs: FrozenSet[str] = field(default_factory=frozenset)
|
|
215
|
+
"""Kwargs to explicitly exclude from client constructor (manual exclusion layer)"""
|
|
216
|
+
|
|
217
|
+
valid_kwargs: Optional[FrozenSet[str]] = None
|
|
218
|
+
"""Valid constructor kwargs from introspection. None means accept all (client uses **kwargs)."""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BaseGenericRotatingClient(Generic[T]):
|
|
222
|
+
"""Base class for generic rotating clients."""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
manager: RotatingKeyManager,
|
|
227
|
+
limit_resolver: Callable[[str, Optional[str]], RateLimits],
|
|
228
|
+
default_model: str,
|
|
229
|
+
config: GenericClientConfig,
|
|
230
|
+
):
|
|
231
|
+
"""
|
|
232
|
+
Initialize the base rotating client.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
manager: The key rotation manager
|
|
236
|
+
limit_resolver: Function to resolve rate limits for a model
|
|
237
|
+
default_model: Default model to use
|
|
238
|
+
config: Client configuration
|
|
239
|
+
"""
|
|
240
|
+
self.manager = manager
|
|
241
|
+
self.limit_resolver = limit_resolver
|
|
242
|
+
self.default_model = default_model
|
|
243
|
+
self.config = config
|
|
244
|
+
self._usage_extractor = config.usage_extractor or default_usage_extractor
|
|
245
|
+
|
|
246
|
+
def _get_fresh_client(self, key_usage: KeyUsage) -> T:
|
|
247
|
+
"""Create a fresh client instance with the key's params."""
|
|
248
|
+
key_params = key_usage.get_client_params()
|
|
249
|
+
|
|
250
|
+
# Merge client_kwargs with key_params (key_params take precedence)
|
|
251
|
+
merged_kwargs = {**self.config.client_kwargs, **key_params}
|
|
252
|
+
|
|
253
|
+
# Layer 1: Apply manual exclusions
|
|
254
|
+
if self.config.excluded_kwargs:
|
|
255
|
+
merged_kwargs = {
|
|
256
|
+
k: v for k, v in merged_kwargs.items()
|
|
257
|
+
if k not in self.config.excluded_kwargs
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Layer 2: Apply introspection filter (if available)
|
|
261
|
+
if self.config.valid_kwargs is not None:
|
|
262
|
+
final_kwargs = {
|
|
263
|
+
k: v for k, v in merged_kwargs.items()
|
|
264
|
+
if k in self.config.valid_kwargs
|
|
265
|
+
}
|
|
266
|
+
# Log filtered kwargs for debugging
|
|
267
|
+
filtered = set(merged_kwargs.keys()) - set(final_kwargs.keys())
|
|
268
|
+
if filtered:
|
|
269
|
+
logger.debug(
|
|
270
|
+
"Introspection filtered kwargs for %s: %s",
|
|
271
|
+
self.config.client_class.__name__, filtered
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
final_kwargs = merged_kwargs
|
|
275
|
+
|
|
276
|
+
return self.config.client_class(**final_kwargs)
|
|
277
|
+
|
|
278
|
+
def _record_usage(self, key_usage: KeyUsage, model_id: str, actual_tokens: int) -> None:
|
|
279
|
+
"""Record usage for a key."""
|
|
280
|
+
self.manager.record_usage(
|
|
281
|
+
key_obj=key_usage,
|
|
282
|
+
model_id=model_id,
|
|
283
|
+
actual_tokens=actual_tokens,
|
|
284
|
+
estimated_tokens=self.config.estimated_tokens,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _extract_usage(self, response: Any) -> int:
|
|
288
|
+
"""Extract token usage from a response."""
|
|
289
|
+
return self._usage_extractor(response)
|
|
290
|
+
|
|
291
|
+
def _get_model_id(self, kwargs: dict) -> str:
|
|
292
|
+
"""Extract model ID from kwargs or use default."""
|
|
293
|
+
return kwargs.get(self.config.model_param, self.default_model)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class SyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
297
|
+
"""Synchronous generic rotating client."""
|
|
298
|
+
|
|
299
|
+
def __getattr__(self, name: str) -> "SyncGenericProxyHelper":
|
|
300
|
+
return SyncGenericProxyHelper(self, [name])
|
|
301
|
+
|
|
302
|
+
def _execute(self, path: List[str], args: tuple, kwargs: dict) -> Any:
|
|
303
|
+
"""Execute a method call with key rotation."""
|
|
304
|
+
model_id = self._get_model_id(kwargs)
|
|
305
|
+
limits = self.limit_resolver(model_id, None)
|
|
306
|
+
|
|
307
|
+
for attempt in range(self.config.max_retries + 1):
|
|
308
|
+
key_usage = self.manager.get_key(model_id, limits, self.config.estimated_tokens)
|
|
309
|
+
if not key_usage:
|
|
310
|
+
raise RuntimeError(f"No available keys for {model_id}")
|
|
311
|
+
|
|
312
|
+
# Create backoff for temporary rate limits
|
|
313
|
+
temp_backoff = ExponentialBackoff(BackoffConfig(
|
|
314
|
+
initial_interval=TEMP_RATE_LIMIT_INITIAL_DELAY,
|
|
315
|
+
max_interval=TEMP_RATE_LIMIT_MAX_DELAY,
|
|
316
|
+
multiplier=TEMP_RATE_LIMIT_MULTIPLIER,
|
|
317
|
+
))
|
|
318
|
+
|
|
319
|
+
for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
|
|
320
|
+
try:
|
|
321
|
+
real_client = self._get_fresh_client(key_usage)
|
|
322
|
+
|
|
323
|
+
# Navigate to the target method
|
|
324
|
+
target = real_client
|
|
325
|
+
for p in path:
|
|
326
|
+
target = getattr(target, p)
|
|
327
|
+
|
|
328
|
+
result = target(*args, **kwargs)
|
|
329
|
+
|
|
330
|
+
# Handle streaming responses
|
|
331
|
+
if hasattr(result, "__iter__") and not isinstance(result, (str, bytes, dict, list)):
|
|
332
|
+
# Check if it looks like a generator/iterator
|
|
333
|
+
if hasattr(result, "__next__") or inspect.isgenerator(result):
|
|
334
|
+
return self._wrap_stream(result, key_usage, model_id)
|
|
335
|
+
|
|
336
|
+
self._record_usage(key_usage, model_id, self._extract_usage(result))
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
# Check for temporary rate limit first - retry with SAME key
|
|
341
|
+
if is_temporary_rate_limit_error(e) and temp_attempt < TEMP_RATE_LIMIT_MAX_RETRIES:
|
|
342
|
+
delay = temp_backoff.get_next_interval()
|
|
343
|
+
logger.info(
|
|
344
|
+
"Temporary rate limit on key ...%s for %s. Waiting %.1fs (%d/%d).",
|
|
345
|
+
get_key_suffix(key_usage.api_key), model_id, delay,
|
|
346
|
+
temp_attempt + 1, TEMP_RATE_LIMIT_MAX_RETRIES
|
|
347
|
+
)
|
|
348
|
+
time.sleep(delay)
|
|
349
|
+
continue # Retry with SAME key
|
|
350
|
+
|
|
351
|
+
# Payment required (quota/credits exhausted) - key is dead
|
|
352
|
+
# for this process, rotate to next key
|
|
353
|
+
if is_payment_required_error(e) and attempt < self.config.max_retries:
|
|
354
|
+
logger.warning(
|
|
355
|
+
"402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
|
|
356
|
+
model_id, get_key_suffix(key_usage.api_key),
|
|
357
|
+
attempt + 1, self.config.max_retries + 1
|
|
358
|
+
)
|
|
359
|
+
key_usage.mark_dead()
|
|
360
|
+
self.manager.force_rotate_index()
|
|
361
|
+
time.sleep(KEY_ROTATION_DELAY_SECONDS)
|
|
362
|
+
break # Break inner loop, continue outer loop with new key
|
|
363
|
+
|
|
364
|
+
# Hard rate limit - rotate to next key
|
|
365
|
+
if is_rate_limit_error(e) and attempt < self.config.max_retries:
|
|
366
|
+
logger.warning(
|
|
367
|
+
"429/RateLimit hit for %s on key ...%s. Rotating. (Attempt %d/%d)",
|
|
368
|
+
model_id, get_key_suffix(key_usage.api_key),
|
|
369
|
+
attempt + 1, self.config.max_retries + 1
|
|
370
|
+
)
|
|
371
|
+
key_usage.trigger_cooldown()
|
|
372
|
+
self.manager.force_rotate_index()
|
|
373
|
+
time.sleep(KEY_ROTATION_DELAY_SECONDS)
|
|
374
|
+
break # Break inner loop, continue outer loop with new key
|
|
375
|
+
|
|
376
|
+
self._record_usage(key_usage, model_id, 0)
|
|
377
|
+
raise
|
|
378
|
+
else:
|
|
379
|
+
# Inner loop exhausted without success - continue to next key
|
|
380
|
+
if attempt < self.config.max_retries:
|
|
381
|
+
logger.warning(
|
|
382
|
+
"Temporary rate limit retries exhausted for key ...%s. Rotating.",
|
|
383
|
+
get_key_suffix(key_usage.api_key)
|
|
384
|
+
)
|
|
385
|
+
key_usage.trigger_cooldown()
|
|
386
|
+
self.manager.force_rotate_index()
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
raise RuntimeError(f"All retry attempts exhausted for {model_id}")
|
|
390
|
+
|
|
391
|
+
def _wrap_stream(
|
|
392
|
+
self, generator: Generator, key_usage: KeyUsage, model_id: str
|
|
393
|
+
) -> Generator:
|
|
394
|
+
"""Wrap a streaming response to track usage and handle errors."""
|
|
395
|
+
final_tokens = 0
|
|
396
|
+
try:
|
|
397
|
+
for chunk in generator:
|
|
398
|
+
# Try to extract usage from streaming chunks
|
|
399
|
+
chunk_tokens = self._extract_usage(chunk)
|
|
400
|
+
if chunk_tokens:
|
|
401
|
+
final_tokens = chunk_tokens
|
|
402
|
+
yield chunk
|
|
403
|
+
except Exception as e:
|
|
404
|
+
if is_payment_required_error(e):
|
|
405
|
+
logger.warning(
|
|
406
|
+
"402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
|
|
407
|
+
model_id, get_key_suffix(key_usage.api_key)
|
|
408
|
+
)
|
|
409
|
+
key_usage.mark_dead()
|
|
410
|
+
self.manager.force_rotate_index()
|
|
411
|
+
elif is_rate_limit_error(e):
|
|
412
|
+
logger.warning(
|
|
413
|
+
"Rate limit hit during streaming for %s on key ...%s.",
|
|
414
|
+
model_id, get_key_suffix(key_usage.api_key)
|
|
415
|
+
)
|
|
416
|
+
key_usage.trigger_cooldown()
|
|
417
|
+
self.manager.force_rotate_index()
|
|
418
|
+
raise
|
|
419
|
+
finally:
|
|
420
|
+
self._record_usage(key_usage, model_id, final_tokens)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class SyncGenericProxyHelper:
|
|
424
|
+
"""Helper class to build attribute path chains for sync clients."""
|
|
425
|
+
|
|
426
|
+
def __init__(self, client: SyncGenericRotatingClient, path: List[str]):
|
|
427
|
+
self.client = client
|
|
428
|
+
self.path = path
|
|
429
|
+
|
|
430
|
+
def __getattr__(self, name: str) -> "SyncGenericProxyHelper":
|
|
431
|
+
return SyncGenericProxyHelper(self.client, self.path + [name])
|
|
432
|
+
|
|
433
|
+
def __call__(self, *args, **kwargs) -> Any:
|
|
434
|
+
return self.client._execute(self.path, args, kwargs)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class AsyncGenericRotatingClient(BaseGenericRotatingClient[T]):
|
|
438
|
+
"""Asynchronous generic rotating client."""
|
|
439
|
+
|
|
440
|
+
def __getattr__(self, name: str) -> "AsyncGenericProxyHelper":
|
|
441
|
+
return AsyncGenericProxyHelper(self, [name])
|
|
442
|
+
|
|
443
|
+
async def _execute(self, path: List[str], args: tuple, kwargs: dict) -> Any:
|
|
444
|
+
"""Execute a method call with key rotation (async)."""
|
|
445
|
+
model_id = self._get_model_id(kwargs)
|
|
446
|
+
limits = self.limit_resolver(model_id, None)
|
|
447
|
+
|
|
448
|
+
for attempt in range(self.config.max_retries + 1):
|
|
449
|
+
key_usage = self.manager.get_key(model_id, limits, self.config.estimated_tokens)
|
|
450
|
+
if not key_usage:
|
|
451
|
+
raise RuntimeError(f"No available keys for {model_id}")
|
|
452
|
+
|
|
453
|
+
# Create backoff for temporary rate limits
|
|
454
|
+
temp_backoff = ExponentialBackoff(BackoffConfig(
|
|
455
|
+
initial_interval=TEMP_RATE_LIMIT_INITIAL_DELAY,
|
|
456
|
+
max_interval=TEMP_RATE_LIMIT_MAX_DELAY,
|
|
457
|
+
multiplier=TEMP_RATE_LIMIT_MULTIPLIER,
|
|
458
|
+
))
|
|
459
|
+
|
|
460
|
+
for temp_attempt in range(TEMP_RATE_LIMIT_MAX_RETRIES + 1):
|
|
461
|
+
try:
|
|
462
|
+
real_client = self._get_fresh_client(key_usage)
|
|
463
|
+
|
|
464
|
+
# Navigate to the target method
|
|
465
|
+
target = real_client
|
|
466
|
+
for p in path:
|
|
467
|
+
target = getattr(target, p)
|
|
468
|
+
|
|
469
|
+
result = await target(*args, **kwargs)
|
|
470
|
+
|
|
471
|
+
# Handle async streaming responses
|
|
472
|
+
if hasattr(result, "__aiter__"):
|
|
473
|
+
return self._wrap_stream(result, key_usage, model_id)
|
|
474
|
+
|
|
475
|
+
self._record_usage(key_usage, model_id, self._extract_usage(result))
|
|
476
|
+
return result
|
|
477
|
+
|
|
478
|
+
except Exception as e:
|
|
479
|
+
# Check for temporary rate limit first - retry with SAME key
|
|
480
|
+
if is_temporary_rate_limit_error(e) and temp_attempt < TEMP_RATE_LIMIT_MAX_RETRIES:
|
|
481
|
+
delay = temp_backoff.get_next_interval()
|
|
482
|
+
logger.info(
|
|
483
|
+
"Temporary rate limit on key ...%s for %s. Waiting %.1fs (%d/%d).",
|
|
484
|
+
get_key_suffix(key_usage.api_key), model_id, delay,
|
|
485
|
+
temp_attempt + 1, TEMP_RATE_LIMIT_MAX_RETRIES
|
|
486
|
+
)
|
|
487
|
+
await asyncio.sleep(delay)
|
|
488
|
+
continue # Retry with SAME key
|
|
489
|
+
|
|
490
|
+
# Payment required (quota/credits exhausted) - key is dead
|
|
491
|
+
# for this process, rotate to next key
|
|
492
|
+
if is_payment_required_error(e) and attempt < self.config.max_retries:
|
|
493
|
+
logger.warning(
|
|
494
|
+
"402/PaymentRequired hit for %s on key ...%s. Marking key dead and rotating. (Attempt %d/%d)",
|
|
495
|
+
model_id, get_key_suffix(key_usage.api_key),
|
|
496
|
+
attempt + 1, self.config.max_retries + 1
|
|
497
|
+
)
|
|
498
|
+
key_usage.mark_dead()
|
|
499
|
+
self.manager.force_rotate_index()
|
|
500
|
+
await asyncio.sleep(KEY_ROTATION_DELAY_SECONDS)
|
|
501
|
+
break # Break inner loop, continue outer loop with new key
|
|
502
|
+
|
|
503
|
+
# Hard rate limit - rotate to next key
|
|
504
|
+
if is_rate_limit_error(e) and attempt < self.config.max_retries:
|
|
505
|
+
logger.warning(
|
|
506
|
+
"429/RateLimit hit for %s on key ...%s. Rotating. (Attempt %d/%d)",
|
|
507
|
+
model_id, get_key_suffix(key_usage.api_key),
|
|
508
|
+
attempt + 1, self.config.max_retries + 1
|
|
509
|
+
)
|
|
510
|
+
key_usage.trigger_cooldown()
|
|
511
|
+
self.manager.force_rotate_index()
|
|
512
|
+
await asyncio.sleep(KEY_ROTATION_DELAY_SECONDS)
|
|
513
|
+
break # Break inner loop, continue outer loop with new key
|
|
514
|
+
|
|
515
|
+
self._record_usage(key_usage, model_id, 0)
|
|
516
|
+
raise
|
|
517
|
+
else:
|
|
518
|
+
# Inner loop exhausted without success - continue to next key
|
|
519
|
+
if attempt < self.config.max_retries:
|
|
520
|
+
logger.warning(
|
|
521
|
+
"Temporary rate limit retries exhausted for key ...%s. Rotating.",
|
|
522
|
+
get_key_suffix(key_usage.api_key)
|
|
523
|
+
)
|
|
524
|
+
key_usage.trigger_cooldown()
|
|
525
|
+
self.manager.force_rotate_index()
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
raise RuntimeError(f"All retry attempts exhausted for {model_id}")
|
|
529
|
+
|
|
530
|
+
async def _wrap_stream(
|
|
531
|
+
self, generator: AsyncGenerator, key_usage: KeyUsage, model_id: str
|
|
532
|
+
) -> AsyncGenerator:
|
|
533
|
+
"""Wrap an async streaming response to track usage and handle errors."""
|
|
534
|
+
final_tokens = 0
|
|
535
|
+
try:
|
|
536
|
+
async for chunk in generator:
|
|
537
|
+
# Try to extract usage from streaming chunks
|
|
538
|
+
chunk_tokens = self._extract_usage(chunk)
|
|
539
|
+
if chunk_tokens:
|
|
540
|
+
final_tokens = chunk_tokens
|
|
541
|
+
yield chunk
|
|
542
|
+
except Exception as e:
|
|
543
|
+
if is_payment_required_error(e):
|
|
544
|
+
logger.warning(
|
|
545
|
+
"402/PaymentRequired hit during streaming for %s on key ...%s. Marking key dead.",
|
|
546
|
+
model_id, get_key_suffix(key_usage.api_key)
|
|
547
|
+
)
|
|
548
|
+
key_usage.mark_dead()
|
|
549
|
+
self.manager.force_rotate_index()
|
|
550
|
+
elif is_rate_limit_error(e):
|
|
551
|
+
logger.warning(
|
|
552
|
+
"Rate limit hit during streaming for %s on key ...%s.",
|
|
553
|
+
model_id, get_key_suffix(key_usage.api_key)
|
|
554
|
+
)
|
|
555
|
+
key_usage.trigger_cooldown()
|
|
556
|
+
self.manager.force_rotate_index()
|
|
557
|
+
raise
|
|
558
|
+
finally:
|
|
559
|
+
self._record_usage(key_usage, model_id, final_tokens)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class AsyncGenericProxyHelper:
|
|
563
|
+
"""Helper class to build attribute path chains for async clients."""
|
|
564
|
+
|
|
565
|
+
def __init__(self, client: AsyncGenericRotatingClient, path: List[str]):
|
|
566
|
+
self.client = client
|
|
567
|
+
self.path = path
|
|
568
|
+
|
|
569
|
+
def __getattr__(self, name: str) -> "AsyncGenericProxyHelper":
|
|
570
|
+
return AsyncGenericProxyHelper(self.client, self.path + [name])
|
|
571
|
+
|
|
572
|
+
async def __call__(self, *args, **kwargs) -> Any:
|
|
573
|
+
return await self.client._execute(self.path, args, kwargs)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def create_rotating_client(
|
|
577
|
+
client_class: Type[T],
|
|
578
|
+
manager: RotatingKeyManager,
|
|
579
|
+
limit_resolver: Callable[[str, Optional[str]], RateLimits],
|
|
580
|
+
default_model: str,
|
|
581
|
+
api_key_param: str = "api_key",
|
|
582
|
+
is_async: Optional[bool] = None,
|
|
583
|
+
usage_extractor: Optional[Callable[[Any], int]] = None,
|
|
584
|
+
estimated_tokens: int = 1000,
|
|
585
|
+
max_retries: int = 5,
|
|
586
|
+
model_param: str = "model",
|
|
587
|
+
excluded_kwargs: Optional[List[str]] = None,
|
|
588
|
+
**client_kwargs,
|
|
589
|
+
) -> Union[SyncGenericRotatingClient[T], AsyncGenericRotatingClient[T]]:
|
|
590
|
+
"""
|
|
591
|
+
Factory function to create an appropriate rotating client wrapper.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
client_class: The client class to wrap (e.g., Anthropic, TwelveLabs)
|
|
595
|
+
manager: The key rotation manager
|
|
596
|
+
limit_resolver: Function to resolve rate limits for a model
|
|
597
|
+
default_model: Default model to use
|
|
598
|
+
api_key_param: Name of the API key parameter in the client constructor
|
|
599
|
+
is_async: Whether the client is async. If None, auto-detected
|
|
600
|
+
usage_extractor: Function to extract token usage from responses
|
|
601
|
+
estimated_tokens: Estimated tokens per request for rate limiting
|
|
602
|
+
max_retries: Maximum number of key rotations on rate limit errors
|
|
603
|
+
model_param: Name of the model parameter in API calls
|
|
604
|
+
excluded_kwargs: List of kwarg names to explicitly exclude from client constructor
|
|
605
|
+
**client_kwargs: Additional kwargs to pass to the client constructor
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
A rotating client wrapper (sync or async based on client type)
|
|
609
|
+
|
|
610
|
+
Example:
|
|
611
|
+
>>> from anthropic import Anthropic
|
|
612
|
+
>>> client = create_rotating_client(
|
|
613
|
+
... Anthropic,
|
|
614
|
+
... manager=wrapper.manager,
|
|
615
|
+
... limit_resolver=wrapper._resolve_limits,
|
|
616
|
+
... default_model="claude-3-sonnet",
|
|
617
|
+
... )
|
|
618
|
+
>>> response = client.messages.create(...)
|
|
619
|
+
|
|
620
|
+
>>> # With excluded_kwargs for clients that don't accept certain params
|
|
621
|
+
>>> from twelvelabs import TwelveLabs
|
|
622
|
+
>>> client = create_rotating_client(
|
|
623
|
+
... TwelveLabs,
|
|
624
|
+
... manager=wrapper.manager,
|
|
625
|
+
... limit_resolver=wrapper._resolve_limits,
|
|
626
|
+
... default_model="pegasus-1",
|
|
627
|
+
... excluded_kwargs=["model"], # TwelveLabs doesn't accept model
|
|
628
|
+
... )
|
|
629
|
+
"""
|
|
630
|
+
# Auto-detect async if not specified
|
|
631
|
+
if is_async is None:
|
|
632
|
+
is_async = detect_async_client(client_class)
|
|
633
|
+
|
|
634
|
+
# Introspect valid constructor kwargs
|
|
635
|
+
valid_kwargs = get_valid_constructor_kwargs(client_class)
|
|
636
|
+
|
|
637
|
+
config = GenericClientConfig(
|
|
638
|
+
client_class=client_class,
|
|
639
|
+
api_key_param=api_key_param,
|
|
640
|
+
is_async=is_async,
|
|
641
|
+
usage_extractor=usage_extractor,
|
|
642
|
+
estimated_tokens=estimated_tokens,
|
|
643
|
+
max_retries=max_retries,
|
|
644
|
+
model_param=model_param,
|
|
645
|
+
client_kwargs=client_kwargs,
|
|
646
|
+
excluded_kwargs=frozenset(excluded_kwargs or []),
|
|
647
|
+
valid_kwargs=valid_kwargs,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if is_async:
|
|
651
|
+
return AsyncGenericRotatingClient(
|
|
652
|
+
manager=manager,
|
|
653
|
+
limit_resolver=limit_resolver,
|
|
654
|
+
default_model=default_model,
|
|
655
|
+
config=config,
|
|
656
|
+
)
|
|
657
|
+
else:
|
|
658
|
+
return SyncGenericRotatingClient(
|
|
659
|
+
manager=manager,
|
|
660
|
+
limit_resolver=limit_resolver,
|
|
661
|
+
default_model=default_model,
|
|
662
|
+
config=config,
|
|
663
|
+
)
|