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