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,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
+ )