openvidia 2.2.0__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,1010 @@
1
+ """
2
+ Shared state for the running proxy.
3
+
4
+ Single source of truth for keys, cooldowns, RPM tracking and usage stats.
5
+
6
+ Concurrency, accurately: everything here runs on the asyncio event loop. The
7
+ health check and the pre-warm are coroutines, not OS threads — the only
8
+ threading.Thread in the project is the restart helper in webui.py, and it does
9
+ not touch this object. ``self.lock`` (asyncio) serialises the compound
10
+ read-modify-write sequences that span an await; single statements are already
11
+ atomic under the loop.
12
+
13
+ The key list additionally takes a threading.Lock when replaced, because the
14
+ list is swapped wholesale while readers may be iterating it. That is the only
15
+ thing it protects, and it is not a claim that the rest of the class is
16
+ thread-safe: an earlier version of this docstring said it was, which is worse
17
+ than saying nothing, because the next person to add a thread would believe it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import random
24
+ import threading
25
+ import time
26
+ from collections import deque
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+
30
+ # ── Cooldown / RPM constants ──────────────────────────────────────────
31
+
32
+ MAX_RPM = 28 # safe margin under NVIDIA's 40 RPM limit
33
+ RPM_WINDOW = 60.0 # sliding window in seconds
34
+
35
+ # Per-status cooldown durations (seconds).
36
+ # 400/404 are deterministic errors, not key faults — short cooldown, no rotation.
37
+ # 401/403 mean the key is dead — long cooldown, permanent invalidation.
38
+ # 429 respects Retry-After when provided.
39
+ # Adaptive: cooldowns scale with consecutive failures for repeated offenders.
40
+ COOLDOWN_DURATIONS: dict[int, float] = {
41
+ 400: 60.0, # Reduced from 120s - faster recovery for transient client errors
42
+ 401: 3600.0,
43
+ 403: 3600.0,
44
+ 404: 60.0, # Reduced from 120s - endpoint issues may resolve quickly
45
+ 429: 45.0, # Reduced to 45s for faster key recovery under high pool load
46
+ }
47
+ DEFAULT_COOLDOWN = 30.0
48
+
49
+ # Adaptive cooldown: repeated failures get up to ADAPTIVE_COOLDOWN_MAX
50
+ # times the base duration. The exponent is capped at MAX so a key that
51
+ # fails many times in a row is not locked out indefinitely.
52
+ ADAPTIVE_COOLDOWN_MAX = 1.5
53
+
54
+ # Adaptive RPM: per-key ceiling is halved on a 429 (jittered backoff) and
55
+ # restored to MAX_RPM on the next success.
56
+ ADAPTIVE_429_FACTOR = 0.5 # multiply per-key ceiling by this on each 429
57
+ ADAPTIVE_FLOOR_RPM = 14 # minimum floor per-key to maintain throughput
58
+ ADAPTIVE_REHAB_STEP = 10 # fast per-key ceiling growth (+10 RPM) on success
59
+
60
+ # ── Aggregate (account/IP) throttle detection ──────────────────────────
61
+ # NVIDIA documents the free tier as ~40 RPM bound to the API key. Observed
62
+ # behaviour says that is not the only limiter: several distinct keys can take
63
+ # a 429 inside the same second, and the rejection arrives in ~0.16s — far too
64
+ # fast to have reached a model. Independent per-key budgets do not expire in
65
+ # lockstep, so that signature means something upstream is counting across the
66
+ # whole pool (same account, same source address, or an anti-abuse heuristic).
67
+ #
68
+ # It matters because the pool's entire strategy — rotate to another key — is
69
+ # useless against an aggregate limit, and actively harmful: each rotation adds
70
+ # another cooled-down key, so one throttling event takes the whole pool out of
71
+ # service. When the signature appears, stop rotating and wait instead.
72
+ POOL_429_WINDOW = 10.0 # look-back for correlating 429s
73
+ POOL_429_DISTINCT_KEYS = 3 # distinct keys inside the window → aggregate limit
74
+ POOL_THROTTLE_PAUSE = 15.0 # how long to stop spending keys on it
75
+
76
+
77
+ # ── Per-key state ──────────────────────────────────────────────────────
78
+
79
+
80
+ class KeyState:
81
+ """Per-key validity + cooldown + weighted-load tracking.
82
+
83
+ Weighted-load tracking lets ``get_candidate_keys`` prefer the least busy
84
+ key (lowest in-flight + lowest recent RPM) instead of naive round-robin,
85
+ spreading burst traffic across the whole pool so no single key hits its
86
+ RPM ceiling while others idle.
87
+ """
88
+
89
+ __slots__ = (
90
+ "key",
91
+ "is_valid",
92
+ "cooldown_until",
93
+ "last_error",
94
+ "in_flight",
95
+ "last_success_at",
96
+ "last_failure_at",
97
+ "consecutive_failures",
98
+ )
99
+
100
+ def __init__(self, key: str):
101
+ self.key = key
102
+ self.is_valid = True
103
+ self.cooldown_until = 0.0
104
+ self.last_error = ""
105
+ self.in_flight = 0
106
+ self.last_success_at = 0.0
107
+ self.last_failure_at = 0.0
108
+ self.consecutive_failures = 0
109
+
110
+
111
+ @dataclass
112
+ class KeyCooldown:
113
+ """Active cooldown for a key with remaining-time helpers."""
114
+
115
+ until: float = 0.0
116
+ reason: str = ""
117
+ # The upstream status that caused it. A rate limit and a dead socket both
118
+ # park a key, but only one of them can be disproved by a cheap probe.
119
+ status: int = 0
120
+
121
+ @property
122
+ def remaining(self) -> float:
123
+ r = self.until - time.time()
124
+ return r if r > 0 else 0.0
125
+
126
+ @property
127
+ def active(self) -> bool:
128
+ return self.remaining > 0
129
+
130
+
131
+ # ── RPM tracker ────────────────────────────────────────────────────────
132
+
133
+
134
+ class RpmTracker:
135
+ """Sliding-window requests-per-minute counter for a single key.
136
+
137
+ Per-key adaptive ceiling: when NVIDIA's 429 response or Retry-After hint
138
+ a lower effective RPM than MAX_RPM, the key lowers its own ceiling so the
139
+ scheduler throttles it before a 429 actually occurs instead of after.
140
+
141
+ ``observed_ceiling`` is the rate the upstream *explicitly* allowed with a
142
+ Retry-After header (60/retry_after). It is stricter than the halving rule
143
+ because it encodes the server's own throughput estimate: a 30s wait means
144
+ 2 RPM, which halving would never reach. ``can_send`` respects it until
145
+ successful requests earn the ceiling back.
146
+ """
147
+
148
+ __slots__ = ("timestamps", "window", "max_rpm", "observed_ceiling")
149
+
150
+ def __init__(self, window: float = RPM_WINDOW, max_rpm: int = 0):
151
+ self.timestamps: deque[float] = deque()
152
+ self.window = window
153
+ # 0 = inherit global MAX_RPM (legacy default).
154
+ self.max_rpm = max_rpm
155
+ # 0 = no upstream hint received; learn from Retry-After on 429.
156
+ self.observed_ceiling = 0
157
+
158
+ def learn_from_retry_after(self, retry_after_s: float) -> None:
159
+ """Record the ceiling the server implied with its Retry-After."""
160
+ implied = max(1, int(60.0 / max(1.0, retry_after_s)))
161
+ if self.observed_ceiling == 0 or implied < self.observed_ceiling:
162
+ self.observed_ceiling = implied
163
+
164
+ def record(self) -> None:
165
+ now = time.time()
166
+ self.timestamps.append(now)
167
+ self._prune(now)
168
+
169
+ def count(self) -> int:
170
+ self._prune()
171
+ return len(self.timestamps)
172
+
173
+ def can_send(self, max_rpm: int = MAX_RPM) -> bool:
174
+ ceiling = self.max_rpm if self.max_rpm and self.max_rpm < max_rpm else max_rpm
175
+ if self.observed_ceiling:
176
+ ceiling = min(ceiling, self.observed_ceiling)
177
+ return self.count() < ceiling
178
+
179
+ def _prune(self, now: float | None = None) -> None:
180
+ if now is None:
181
+ now = time.time()
182
+ cutoff = now - self.window
183
+ while self.timestamps and self.timestamps[0] < cutoff:
184
+ self.timestamps.popleft()
185
+
186
+
187
+ # ── Usage stats ────────────────────────────────────────────────────────
188
+
189
+
190
+ class KeyUsage:
191
+ __slots__ = ("requests", "success", "failed", "last_used", "last_error")
192
+
193
+ def __init__(self):
194
+ self.requests = 0
195
+ self.success = 0
196
+ self.failed = 0
197
+ self.last_used = 0.0
198
+ self.last_error = ""
199
+
200
+
201
+ class KeyModelHealth:
202
+ """Per-(key, model) health, measured from real traffic.
203
+
204
+ A key that answers a model in 300ms is worth more than one that answers
205
+ the same model in 30s, and the difference is invisible in the per-key and
206
+ per-model aggregates — only the cross product sees it. ``score()`` folds
207
+ success rate and median TTFT into a 0..1 value used to break scheduling
208
+ ties: keys with a proven record on the requested model are preferred,
209
+ everything else stays untouched.
210
+ """
211
+
212
+ __slots__ = ("requests", "success", "ttfts", "consecutive_failures")
213
+
214
+ def __init__(self):
215
+ self.requests = 0
216
+ self.success = 0
217
+ self.ttfts: deque[float] = deque(maxlen=20)
218
+ self.consecutive_failures = 0
219
+
220
+ def score(self) -> float:
221
+ """0..1, higher is better. No data yet → neutral 0.5 (no bias)."""
222
+ if not self.requests:
223
+ return 0.5
224
+ success_rate = self.success / self.requests
225
+ if self.ttfts:
226
+ # Penalty grows from 0 (instant) to 1 at 30s median TTFT.
227
+ s = sorted(self.ttfts)
228
+ median = s[len(s) // 2]
229
+ latency_penalty = min(1.0, median / 30.0)
230
+ else:
231
+ latency_penalty = 0.0
232
+ return 0.6 * success_rate + 0.4 * (1.0 - latency_penalty)
233
+
234
+ def as_dict(self) -> dict:
235
+ s = sorted(self.ttfts)
236
+ return {
237
+ "requests": self.requests,
238
+ "success": self.success,
239
+ "median_ttft": round(s[len(s) // 2], 1) if s else None,
240
+ "consecutive_failures": self.consecutive_failures,
241
+ "score": round(self.score(), 2),
242
+ }
243
+
244
+
245
+ class ModelHealth:
246
+ """Per-model health, measured from real traffic.
247
+
248
+ Provider capacity for one model collapses without warning while the rest
249
+ stay fast, and the proxy is the only component that sees it happen. Every
250
+ request already carries the evidence — how long the first byte took, and
251
+ what status came back — so nothing needs to be probed: we just keep score
252
+ and say so out loud when a model stops being usable.
253
+ """
254
+
255
+ __slots__ = (
256
+ "requests",
257
+ "success",
258
+ "gateway_timeouts",
259
+ "rate_limited",
260
+ "too_slow",
261
+ "ttfts",
262
+ "warned_at",
263
+ "consecutive_failures",
264
+ "circuit_opened_at",
265
+ )
266
+
267
+ # Circuit breaker: open after this many consecutive failures
268
+ CIRCUIT_OPEN_AFTER = 3
269
+ # Auto-reset after this many seconds (allow model to recover)
270
+ CIRCUIT_RESET_AFTER = 120.0
271
+
272
+ def __init__(self):
273
+ self.requests = 0
274
+ self.success = 0
275
+ self.gateway_timeouts = 0 # 502/503/504: provider gave up on the model
276
+ self.rate_limited = 0 # 429
277
+ self.too_slow = 0 # no first byte before our own read timeout
278
+ self.ttfts: deque[float] = deque(maxlen=20)
279
+ self.warned_at = 0.0
280
+ self.consecutive_failures = 0
281
+ self.circuit_opened_at = 0.0
282
+
283
+ @property
284
+ def is_circuit_open(self) -> bool:
285
+ """True when this model should be skipped (too many consecutive failures)."""
286
+ if self.consecutive_failures < self.CIRCUIT_OPEN_AFTER:
287
+ return False
288
+ if time.time() - self.circuit_opened_at > self.CIRCUIT_RESET_AFTER:
289
+ # Auto-reset: give model a chance to recover
290
+ self.consecutive_failures = 0
291
+ self.circuit_opened_at = 0.0
292
+ return False
293
+ return True
294
+
295
+ def record_failure(self) -> None:
296
+ self.consecutive_failures += 1
297
+ if self.consecutive_failures == self.CIRCUIT_OPEN_AFTER:
298
+ self.circuit_opened_at = time.time()
299
+
300
+ def record_success_reset(self) -> None:
301
+ self.consecutive_failures = 0
302
+ self.circuit_opened_at = 0.0
303
+
304
+ @property
305
+ def median_ttft(self) -> float:
306
+ if not self.ttfts:
307
+ return 0.0
308
+ s = sorted(self.ttfts)
309
+ return s[len(s) // 2]
310
+
311
+ @property
312
+ def failure_rate(self) -> float:
313
+ if not self.requests:
314
+ return 0.0
315
+ return 1.0 - (self.success / self.requests)
316
+
317
+ def as_dict(self) -> dict:
318
+ return {
319
+ "requests": self.requests,
320
+ "success": self.success,
321
+ "gateway_timeouts": self.gateway_timeouts,
322
+ "rate_limited": self.rate_limited,
323
+ "too_slow": self.too_slow,
324
+ "median_ttft": round(self.median_ttft, 1),
325
+ "failure_rate": round(self.failure_rate, 2),
326
+ "circuit_open": self.is_circuit_open,
327
+ "consecutive_failures": self.consecutive_failures,
328
+ }
329
+
330
+
331
+ # A model is called out once its recent record is this bad. Small samples lie,
332
+ # so require a handful of attempts before saying anything.
333
+ _MODEL_WARN_MIN_REQUESTS = 4
334
+ _MODEL_WARN_FAILURE_RATE = 0.5
335
+ _MODEL_WARN_INTERVAL = 120.0 # seconds between repeats, per model
336
+
337
+
338
+ class ProxyStats:
339
+ def __init__(self, current_index: int = 0):
340
+ self.requests = 0
341
+ self.rotations = 0
342
+ self.success = 0
343
+ self.current_index = current_index
344
+ self.active_key_index: int = current_index
345
+ self.key_usage: dict[str, KeyUsage] = {}
346
+
347
+ def record_key_usage(self, key: str, ok: bool = True, error: str = "") -> None:
348
+ u = self.key_usage.get(key)
349
+ if u is None:
350
+ u = KeyUsage()
351
+ self.key_usage[key] = u
352
+ u.requests += 1
353
+ u.last_used = time.time()
354
+ if ok:
355
+ u.success += 1
356
+ else:
357
+ u.failed += 1
358
+ u.last_error = error
359
+
360
+
361
+ # ── ProxyState ────────────────────────────────────────────────────────
362
+
363
+
364
+ class ProxyState:
365
+ def __init__(
366
+ self,
367
+ keys: list[str],
368
+ stats: ProxyStats,
369
+ log_cb: Callable[[str], None],
370
+ port: int = 1919,
371
+ redis_sync=None,
372
+ ):
373
+ self._keys: list[str] = list(keys)
374
+ self._key_states: dict[str, KeyState] = {k: KeyState(k) for k in keys}
375
+ self._keys_write_lock = threading.Lock()
376
+ self.stats = stats
377
+ self.port = port
378
+ self.lock = asyncio.Lock()
379
+ self.log_buffer: deque = deque(maxlen=500)
380
+ self._log_cb = log_cb
381
+
382
+ self.active_model: str | None = None
383
+ self.running: bool = True
384
+ self.health_task: asyncio.Task | None = None
385
+ self.upstream_task: asyncio.Task | None = None
386
+
387
+ self.cooldowns: dict[str, KeyCooldown] = {}
388
+ self.rpm: dict[str, RpmTracker] = {}
389
+ # (timestamp, key) for recent 429s, used to tell a per-key rate limit
390
+ # apart from an account-wide one. See POOL_429_WINDOW.
391
+ self._recent_429: deque[tuple[float, str]] = deque()
392
+ self.pool_throttled_until: float = 0.0
393
+ # Per-model health, learned from real traffic (see ModelHealth).
394
+ self.model_health: dict[str, ModelHealth] = {}
395
+ # Per-(key, model) health, learned from real traffic (KeyModelHealth).
396
+ self.key_model_health: dict[tuple[str, str], KeyModelHealth] = {}
397
+ # Upstream (NVIDIA) reachability, probed periodically so the dashboard
398
+ # can tell "NVIDIA is down" apart from "the proxy broke". status is one
399
+ # of "ok" / "slow" / "down" / "unknown"; latency_ms is the last probe's
400
+ # time to response; detail carries the human reason when not ok.
401
+ self.upstream: dict[str, object] = {
402
+ "status": "unknown",
403
+ "latency_ms": 0,
404
+ "last_check": 0.0,
405
+ "detail": "",
406
+ }
407
+ # Optional cross-node synchronisation (Redis). None = single-node.
408
+ self.redis_sync = redis_sync
409
+
410
+ try:
411
+ self.loop = asyncio.get_running_loop()
412
+ except RuntimeError:
413
+ # Python 3.14 removed the implicit "create if missing"
414
+ # behavior of get_event_loop; fall back to a new short-lived
415
+ # loop so ProxyState can be constructed outside a running loop
416
+ # (tests, ad-hoc scripts). In a FastAPI worker the running
417
+ # loop branch above already handles the normal path.
418
+ self.loop = asyncio.new_event_loop()
419
+ self.listeners: set[asyncio.Queue] = set()
420
+
421
+ @property
422
+ def keys(self) -> list[str]:
423
+ return self._keys
424
+
425
+ def set_upstream_status(
426
+ self,
427
+ status: str,
428
+ latency_ms: float = 0.0,
429
+ detail: str = "",
430
+ ) -> None:
431
+ """Record the latest upstream (NVIDIA) probe result."""
432
+ self.upstream = {
433
+ "status": status,
434
+ "latency_ms": round(latency_ms, 1),
435
+ "last_check": time.time(),
436
+ "detail": detail,
437
+ }
438
+
439
+ @keys.setter
440
+ def keys(self, new_keys: list[str]) -> None:
441
+ """Replace the pool, carrying over state for keys that remain.
442
+
443
+ Everything keyed by the API string is pruned together. Only
444
+ ``_key_states`` used to be rebuilt, so ``cooldowns``, ``rpm`` and
445
+ ``stats.key_usage`` grew for the life of the process and — worse —
446
+ outlived the key itself. Since a key is identified by its own string,
447
+ removing one that a 401 had parked for an hour and pasting it back
448
+ produced a fresh KeyState with is_valid=True that is_key_healthy()
449
+ still rejected, from a cooldown attached to a key the pool no longer
450
+ believed it had.
451
+ """
452
+ with self._keys_write_lock:
453
+ updated_states = {}
454
+ for k in new_keys:
455
+ if k in self._key_states:
456
+ updated_states[k] = self._key_states[k]
457
+ else:
458
+ updated_states[k] = KeyState(k)
459
+ self._keys = list(new_keys)
460
+ self._key_states = updated_states
461
+
462
+ live = set(new_keys)
463
+ for gone in [k for k in self.cooldowns if k not in live]:
464
+ del self.cooldowns[gone]
465
+ for gone in [k for k in self.rpm if k not in live]:
466
+ del self.rpm[gone]
467
+ for gone in [k for k in self.stats.key_usage if k not in live]:
468
+ del self.stats.key_usage[gone]
469
+
470
+ @property
471
+ def key_states(self) -> dict[str, KeyState]:
472
+ with self._keys_write_lock:
473
+ return dict(self._key_states)
474
+
475
+ # ── Logging / SSE push ──────────────────────────────────────────
476
+
477
+ def log_cb(self, msg: str) -> None:
478
+ self._log_cb(msg)
479
+ self.log_buffer.append(msg)
480
+ if self.loop and self.loop.is_running():
481
+ for q in list(self.listeners):
482
+ self.loop.call_soon_threadsafe(q.put_nowait, msg)
483
+
484
+ # ── Cooldown API ─────────────────────────────────────────────────
485
+
486
+ def is_key_on_cooldown(self, key: str) -> bool:
487
+ cd = self.cooldowns.get(key)
488
+ return cd is not None and cd.active
489
+
490
+ def cooldown_remaining(self, key: str) -> float:
491
+ cd = self.cooldowns.get(key)
492
+ return cd.remaining if cd is not None else 0.0
493
+
494
+ def cooldown_reason(self, key: str) -> str:
495
+ cd = self.cooldowns.get(key)
496
+ return cd.reason if cd is not None else ""
497
+
498
+ def set_cooldown(
499
+ self,
500
+ key: str,
501
+ reason: str = "",
502
+ duration: float = DEFAULT_COOLDOWN,
503
+ status: int = 0,
504
+ ) -> None:
505
+ self.cooldowns[key] = KeyCooldown(
506
+ until=time.time() + duration, reason=reason, status=status
507
+ )
508
+ self._emit(
509
+ "cooldown",
510
+ key=key,
511
+ until=self.cooldowns[key].until,
512
+ reason=reason,
513
+ status=status,
514
+ )
515
+
516
+ def cooldown_status(self, key: str) -> int:
517
+ cd = self.cooldowns.get(key)
518
+ return cd.status if cd is not None else 0
519
+
520
+ # ── Aggregate throttle ──────────────────────────────────────────
521
+
522
+ def note_rate_limited(self, key: str) -> bool:
523
+ """Record a 429 and report whether it looks account-wide rather than per-key.
524
+
525
+ Returns True when the pool has just been put on an aggregate pause.
526
+ """
527
+ now = time.time()
528
+ self._recent_429.append((now, key))
529
+ cutoff = now - POOL_429_WINDOW
530
+ while self._recent_429 and self._recent_429[0][0] < cutoff:
531
+ self._recent_429.popleft()
532
+
533
+ if now < self.pool_throttled_until:
534
+ return False # already paused; don't re-log on every straggler
535
+ distinct = {k for _ts, k in self._recent_429}
536
+ if len(distinct) < POOL_429_DISTINCT_KEYS:
537
+ return False
538
+
539
+ self.pool_throttled_until = now + POOL_THROTTLE_PAUSE
540
+ self._emit("pool_throttle", until=self.pool_throttled_until)
541
+ self.log_cb(
542
+ f"⏸ {len(distinct)} keys rate-limited within {POOL_429_WINDOW:.0f}s — this is an "
543
+ f"account-wide limit, not per-key. Pausing {POOL_THROTTLE_PAUSE:.0f}s instead of "
544
+ f"rotating (rotation cannot beat a shared quota)."
545
+ )
546
+ return True
547
+
548
+ def pool_throttle_remaining(self) -> float:
549
+ r = self.pool_throttled_until - time.time()
550
+ return r if r > 0 else 0.0
551
+
552
+ def is_pool_throttled(self) -> bool:
553
+ return self.pool_throttle_remaining() > 0
554
+
555
+ def clear_cooldown(self, key: str) -> None:
556
+ self.cooldowns.pop(key, None)
557
+
558
+ def mark_key_failed(
559
+ self,
560
+ key: str,
561
+ status: int = 0,
562
+ retry_after: str | None = None,
563
+ error_body: str | None = None,
564
+ ) -> None:
565
+ """Record a failed attempt, set cooldown, and (on 429) tighten RPM.
566
+
567
+ Adaptive RPM: NVIDIA's 429 / Retry-After is a signal about *future*
568
+ throughput, not just the past. If we keep the ceiling at MAX_RPM we
569
+ will re-429 the instant the cooldown expires. We step the per-key
570
+ ceiling down and slowly restore it on the next success — exactly how
571
+ a well-behaved client backs off without surrendering throughput
572
+ forever.
573
+
574
+ Adaptive Cooldown: Consecutive failures increase cooldown duration
575
+ using exponential backoff with a cap, preventing hammering of failing
576
+ endpoints while allowing faster recovery for transient errors.
577
+
578
+ Detailed Error Logging: HTTP status codes and error bodies are logged
579
+ for debugging 400/404/500 errors.
580
+ """
581
+ ks = self._key_states.get(key)
582
+ if ks is not None:
583
+ ks.last_failure_at = time.time()
584
+ ks.consecutive_failures = (ks.consecutive_failures or 0) + 1
585
+
586
+ # Build detailed error message for logging
587
+ error_details = f"HTTP {status}" if status else "connection error"
588
+ if error_body:
589
+ error_details += f" (body: {error_body[:100]})" # Truncate long bodies
590
+
591
+ if status == 429:
592
+ multiplier = None # set below; None means "compute from adaptive formula"
593
+ learned_retry_after: float | None = None
594
+ if retry_after:
595
+ try:
596
+ # Trust NVIDIA's Retry-After exactly — do NOT apply the
597
+ # adaptive multiplier. NVIDIA's value already encodes how
598
+ # long to wait; multiplying it just makes recovery slower
599
+ # than the server actually requires, extending the doom
600
+ # loop where all keys sit locked out longer than needed.
601
+ base_duration = float(retry_after)
602
+ multiplier = 1.0 # honour upstream's explicit window
603
+ learned_retry_after = base_duration
604
+ except (ValueError, TypeError):
605
+ base_duration = COOLDOWN_DURATIONS[429]
606
+ multiplier = None # compute adaptively below
607
+ # Jitter up to 10s (was 30s): smaller spread reduces thundering
608
+ # herd while still staggering simultaneous 429s.
609
+ if multiplier is None:
610
+ _r = random.Random(int(time.time()) ^ (hash(key) & 0xFFFFFFFF))
611
+ base_duration = COOLDOWN_DURATIONS[429] + _r.uniform(0.0, 10.0)
612
+ # Apply adaptive multiplier only when we chose the base (no
613
+ # Retry-After was provided). With MULTIPLIER=1.5 and MAX=1.5
614
+ # the exponent never exceeds the cap, so a simple threshold
615
+ # is equivalent and clearer than min(pow(...), cap).
616
+ failures = ks.consecutive_failures if ks is not None else 1
617
+ if multiplier is None:
618
+ multiplier = 1.0 if failures <= 1 else ADAPTIVE_COOLDOWN_MAX
619
+ duration = base_duration * multiplier
620
+
621
+ reason = f"429 rate-limited (cooldown {duration:.0f}s, attempt {failures})"
622
+ if error_body:
623
+ reason += f" - {error_body[:50]}"
624
+
625
+ self.note_rate_limited(key)
626
+
627
+ # Log detailed 429 info
628
+ self.log_cb(f"⚠ key[{self._keys.index(key) if key in self._keys else '?'}] {reason}")
629
+
630
+ # Tighten the per-key RPM ceiling: if current was MAX_RPM, drop
631
+ # it by ADAPTIVE_429_FACTOR (default 0.5) but never below
632
+ # ADAPTIVE_FLOOR_RPM. This prevents the post-cooldown spike that
633
+ # would otherwise re-trigger 429 immediately.
634
+ if ks is not None:
635
+ tracker = self.rpm.setdefault(key, RpmTracker())
636
+ ceil = tracker.max_rpm or MAX_RPM
637
+ tracker.max_rpm = max(
638
+ ADAPTIVE_FLOOR_RPM,
639
+ int(ceil * ADAPTIVE_429_FACTOR),
640
+ )
641
+ # Retry-After is the server's own throughput estimate: a 30s
642
+ # wait means 2 RPM, which the halving rule would never reach.
643
+ if learned_retry_after is not None:
644
+ tracker.learn_from_retry_after(learned_retry_after)
645
+ elif retry_after:
646
+ # An explicit retry_after is the caller stating how long this key
647
+ # should sit out, and it used to be honoured for 429 only: a 503
648
+ # asking for 10s silently became the 30s default, so the log said
649
+ # one thing ("10s cooldown") and the pool did another. A provider
650
+ # outage then drained the pool three times faster than the code
651
+ # claimed it would.
652
+ try:
653
+ duration = float(retry_after)
654
+ except (ValueError, TypeError):
655
+ duration = COOLDOWN_DURATIONS.get(status, DEFAULT_COOLDOWN)
656
+ reason = f"{error_details} (cooldown {duration:.0f}s)"
657
+ elif status in COOLDOWN_DURATIONS:
658
+ base_duration = COOLDOWN_DURATIONS[status]
659
+ # Apply adaptive multiplier for repeated failures (except auth errors)
660
+ if status not in (401, 403) and ks is not None and ks.consecutive_failures > 1:
661
+ multiplier = ADAPTIVE_COOLDOWN_MAX
662
+ duration = base_duration * multiplier
663
+ reason = (
664
+ f"{error_details} (cooldown {duration:.0f}s, attempt {ks.consecutive_failures})"
665
+ )
666
+ else:
667
+ duration = base_duration
668
+ reason = f"{error_details} (cooldown {duration:.0f}s)"
669
+ else:
670
+ duration = DEFAULT_COOLDOWN
671
+ reason = error_details
672
+
673
+ self.set_cooldown(key, reason=reason, duration=duration, status=status)
674
+
675
+ # Log detailed error information for debugging
676
+ if status in (400, 404, 500, 502, 503):
677
+ log_prefix = "⧉ error" if status >= 500 else "⚠ error"
678
+ key_idx = self._keys.index(key) if key in self._keys else "?"
679
+ self.log_cb(f"{log_prefix}: key[{key_idx}] {reason}")
680
+ if error_body:
681
+ self.log_cb(f" └─ Response body: {error_body[:200]}")
682
+
683
+ if status in (401, 403):
684
+ if ks is not None:
685
+ ks.is_valid = False
686
+ ks.last_error = error_details
687
+ self._emit("key_invalid", key=key)
688
+ self.log_cb(f"⚠ key marked INVALID ({error_details})")
689
+ elif status in (400, 404, 429):
690
+ if ks is not None:
691
+ ks.cooldown_until = time.time() + duration
692
+ ks.last_error = reason
693
+ else:
694
+ if ks is not None:
695
+ ks.cooldown_until = time.time() + duration
696
+ ks.last_error = reason
697
+
698
+ def restore_key(self, key: str, *, rehab_rpm: bool = True) -> None:
699
+ """Return a key to the pool.
700
+
701
+ ``rehab_rpm=False`` clears the cooldown without giving throughput back.
702
+ The health check needs that distinction: it proves a key is alive with
703
+ GET /v1/models, which is a metadata endpoint that answers 200 even
704
+ while chat/completions is rate-limited for that same key. Treating that
705
+ as evidence of recovered throughput undid the backoff the pool had just
706
+ learned, and the key went straight back into 429 — visible in the logs
707
+ as "revived" followed within a minute by the same key rate-limited.
708
+ """
709
+ self.clear_cooldown(key)
710
+ ks = self._key_states.get(key)
711
+ if ks:
712
+ ks.cooldown_until = 0.0
713
+ ks.is_valid = True
714
+ ks.consecutive_failures = 0
715
+ ks.last_success_at = time.time()
716
+ if not rehab_rpm:
717
+ return
718
+ # Graceful RPM rehab: bump the per-key ceiling back up by
719
+ # ADAPTIVE_REHAB_STEP capped at MAX_RPM. A key that was stepped down on
720
+ # 429 earns throughput back one successful window at a time, instead of
721
+ # snapping straight to MAX_RPM (which would risk re-429).
722
+ tracker = self.rpm.get(key)
723
+ if tracker is None:
724
+ return
725
+ if tracker.max_rpm and tracker.max_rpm < MAX_RPM:
726
+ new_ceiling = min(MAX_RPM, tracker.max_rpm + ADAPTIVE_REHAB_STEP)
727
+ tracker.max_rpm = new_ceiling
728
+ if new_ceiling >= MAX_RPM:
729
+ tracker.max_rpm = 0 # fully rehabbed → inherit global ceiling
730
+
731
+ # ── RPM API ─────────────────────────────────────────────────────
732
+
733
+ def record_key_model_result(
734
+ self,
735
+ key: str,
736
+ model: str,
737
+ *,
738
+ ok: bool = False,
739
+ ttft: float | None = None,
740
+ ) -> None:
741
+ """Score one attempt against the (key, model) pair.
742
+
743
+ Kept deliberately lean: the per-model circuit breaker and per-key
744
+ cooldowns already own failure handling; this only feeds the scheduling
745
+ score that breaks ties in ``get_candidate_keys``.
746
+ """
747
+ if not key or not model:
748
+ return
749
+ h = self.key_model_health.get((key, model))
750
+ if h is None:
751
+ h = KeyModelHealth()
752
+ self.key_model_health[(key, model)] = h
753
+ h.requests += 1
754
+ if ok:
755
+ h.success += 1
756
+ if ttft is not None:
757
+ h.ttfts.append(ttft)
758
+ else:
759
+ h.consecutive_failures += 1
760
+
761
+ def key_model_score(self, key: str, model: str) -> float:
762
+ """0..1 scheduling score of ``key`` on ``model``; neutral when unknown."""
763
+ h = self.key_model_health.get((key, model))
764
+ return h.score() if h is not None else 0.5
765
+
766
+ def apply_remote_event(self, event: dict) -> None:
767
+ """Apply a cooldown/health event broadcast by another node.
768
+
769
+ Deliberately does NOT re-emit: the event already came in through the
770
+ bus, and re-broadcasting it would loop it around the cluster forever.
771
+ """
772
+ kind = event.get("kind")
773
+ try:
774
+ if kind == "cooldown":
775
+ until = float(event.get("until") or 0)
776
+ if until > time.time():
777
+ self.cooldowns[event["key"]] = KeyCooldown(
778
+ until=until,
779
+ reason=event.get("reason", "remote"),
780
+ status=int(event.get("status") or 0),
781
+ )
782
+ self.log_cb(f"🌐 remote cooldown: key[{event['key'][:6]}…] until {until:.0f}")
783
+ elif kind == "key_invalid":
784
+ ks = self._key_states.get(event.get("key", ""))
785
+ if ks is not None:
786
+ ks.is_valid = False
787
+ elif kind == "pool_throttle":
788
+ until = float(event.get("until") or 0)
789
+ if until > self.pool_throttled_until:
790
+ self.pool_throttled_until = until
791
+ elif kind == "model_circuit":
792
+ model = event.get("model", "")
793
+ if event.get("opened"):
794
+ h = self.model_health.get(model)
795
+ if h is None:
796
+ h = ModelHealth()
797
+ self.model_health[model] = h
798
+ h.consecutive_failures = max(h.consecutive_failures, h.CIRCUIT_OPEN_AFTER)
799
+ h.circuit_opened_at = time.time()
800
+ elif model in self.model_health:
801
+ self.model_health[model].record_success_reset()
802
+ except (KeyError, TypeError, ValueError):
803
+ self.log_cb(f"🌐 dropped malformed remote event: {str(event)[:120]}")
804
+
805
+ def _emit(self, kind: str, **data: object) -> None:
806
+ """Best-effort broadcast of a state change to sibling nodes."""
807
+ if self.redis_sync is None:
808
+ return
809
+ try:
810
+ self.redis_sync.broadcast({"kind": kind, **data})
811
+ except Exception: # noqa: BLE001 — the bus must never break the proxy
812
+ pass
813
+
814
+ def record_model_result(
815
+ self,
816
+ model: str,
817
+ *,
818
+ ok: bool = False,
819
+ status: int = 0,
820
+ ttft: float | None = None,
821
+ too_slow: bool = False,
822
+ ) -> None:
823
+ """Score one attempt against a model and warn when it stops working."""
824
+ if not model:
825
+ return
826
+ h = self.model_health.get(model)
827
+ if h is None:
828
+ h = ModelHealth()
829
+ self.model_health[model] = h
830
+ h.requests += 1
831
+ if ok:
832
+ h.success += 1
833
+ h.record_success_reset()
834
+ if ttft is not None:
835
+ h.ttfts.append(ttft)
836
+ return
837
+ # Record failure for circuit breaker
838
+ h.record_failure()
839
+ if h.consecutive_failures == h.CIRCUIT_OPEN_AFTER:
840
+ # Sibling nodes should stop spending time on this model too.
841
+ self._emit("model_circuit", model=model, opened=True)
842
+ if too_slow:
843
+ h.too_slow += 1
844
+ elif status in (502, 503, 504):
845
+ h.gateway_timeouts += 1
846
+ elif status == 429:
847
+ h.rate_limited += 1
848
+
849
+ if (
850
+ h.requests >= _MODEL_WARN_MIN_REQUESTS
851
+ and h.failure_rate >= _MODEL_WARN_FAILURE_RATE
852
+ and time.time() - h.warned_at > _MODEL_WARN_INTERVAL
853
+ ):
854
+ h.warned_at = time.time()
855
+ detail = []
856
+ if h.gateway_timeouts:
857
+ detail.append(f"{h.gateway_timeouts}× 503/504 provider down")
858
+ if h.too_slow:
859
+ detail.append(f"{h.too_slow}× timeout")
860
+ if h.rate_limited:
861
+ detail.append(f"{h.rate_limited}× 429")
862
+ if h.median_ttft:
863
+ detail.append(f"TTFT ~{h.median_ttft:.0f}s")
864
+ circuit = " 🔴 CIRCUIT OPEN" if h.is_circuit_open else ""
865
+ self.log_cb(
866
+ f"⚠ {model}: {h.success}/{h.requests} OK"
867
+ + (f" ({', '.join(detail)})" if detail else "")
868
+ + circuit
869
+ )
870
+
871
+ def is_model_circuit_open(self, model: str) -> bool:
872
+ """True if circuit breaker has tripped for this model."""
873
+ h = self.model_health.get(model)
874
+ return h is not None and h.is_circuit_open
875
+
876
+ def record_request(self, key: str) -> None:
877
+ t = self.rpm.get(key)
878
+ if t is None:
879
+ t = RpmTracker()
880
+ self.rpm[key] = t
881
+ t.record()
882
+
883
+ def key_rpm(self, key: str) -> int:
884
+ t = self.rpm.get(key)
885
+ return t.count() if t is not None else 0
886
+
887
+ def key_can_send_rpm(self, key: str) -> bool:
888
+ t = self.rpm.get(key)
889
+ return t is None or t.can_send()
890
+
891
+ def begin_in_flight(self, key: str) -> None:
892
+ """Mark a key as serving a request; weighted by get_candidate_keys.
893
+
894
+ Every caller must pair this with end_in_flight in a finally block: the
895
+ scheduling cost weighs in_flight at 4, so one leak per stream is enough
896
+ to walk a key out of the pool over an afternoon.
897
+ """
898
+ ks = self._key_states.get(key)
899
+ if ks is not None:
900
+ # No clamp on the way up — the previous max(0, x + 1) suggested the
901
+ # count could be negative here, which it cannot.
902
+ ks.in_flight += 1
903
+
904
+ def end_in_flight(self, key: str) -> None:
905
+ ks = self._key_states.get(key)
906
+ if ks is not None:
907
+ ks.in_flight = max(0, ks.in_flight - 1)
908
+
909
+ # ── Composite helpers ───────────────────────────────────────────
910
+
911
+ def is_key_healthy(self, key: str) -> bool:
912
+ ks = self._key_states.get(key)
913
+ if ks and not ks.is_valid:
914
+ return False
915
+ return not self.is_key_on_cooldown(key)
916
+
917
+ def get_candidate_keys(self, model: str | None = None) -> list[tuple[int, str]]:
918
+ """
919
+ Return ``(index, key)`` candidates for the current request, ordered by
920
+ **least-loaded-first** (in-flight + recent RPM) instead of naive
921
+ round-robin. Round-robin forced concurrent bursts onto the same
922
+ ``current_index``; with the weighted cost below, N simultaneous
923
+ requests each take a different key (max-min fairness across the pool).
924
+
925
+ When ``model`` is given, a small bonus based on the key's proven
926
+ record on that model breaks ties: among equally-loaded keys, the one
927
+ that answered this model fast and reliably wins. The bonus (max 3.0,
928
+ vs in_flight×4) only ever nudges, never overrides load.
929
+
930
+ The first entry is the best key; the rest is the healthy pool sorted
931
+ by cost, with cooldown keys appended last as a degraded fallback.
932
+ """
933
+ scored: list[tuple[float, int, str]] = []
934
+ cooldown: list[tuple[int, str, float]] = []
935
+
936
+ for idx, key in enumerate(self._keys):
937
+ ks = self._key_states.get(key)
938
+ if not ks or not ks.is_valid:
939
+ continue
940
+ if self.is_key_on_cooldown(key):
941
+ cooldown.append((idx, key, self.cooldown_remaining(key)))
942
+ continue
943
+ cost = (ks.in_flight * 4) + self.key_rpm(key) + ks.consecutive_failures * 8
944
+ if model:
945
+ cost -= self.key_model_score(key, model) * 3.0
946
+ scored.append((cost, idx, key))
947
+
948
+ scored.sort(key=lambda x: (x[0], x[1]))
949
+ available = [(idx, key) for _, idx, key in scored]
950
+
951
+ if not available and cooldown:
952
+ cooldown.sort(key=lambda x: x[2])
953
+ available = [(idx, key) for idx, key, _ in cooldown]
954
+ self.log_cb("⚠ No active keys outside cooldown, reusing cooldown keys")
955
+
956
+ if not available:
957
+ return []
958
+
959
+ next_candidate_idx = available[0][0]
960
+ self.stats.current_index = (next_candidate_idx + 1) % len(self._keys)
961
+ self.stats.active_key_index = next_candidate_idx
962
+
963
+ return available
964
+
965
+ # ── Stats for UI ────────────────────────────────────────────────
966
+
967
+ def count_live_candidates(self) -> tuple[int, int]:
968
+ """Return ``(live_rpm_eligible, total_valid)`` for saturation gating.
969
+
970
+ ``live_rpm_eligible`` counts keys that are NOT on cooldown AND have
971
+ RPM headroom right now (the realistic set a rotation loop could
972
+ actually succeed on). ``total_valid`` counts keys not permanently
973
+ invalidated. Callers use this to fast-fail rotation when the pool is
974
+ saturated instead of serially hammering all ``len(keys)`` candidates
975
+ with a 120s timeout each (the historical Codex CLI block).
976
+ """
977
+ live = 0
978
+ valid = 0
979
+ for key in self._keys:
980
+ ks = self._key_states.get(key)
981
+ if not ks or not ks.is_valid:
982
+ continue
983
+ valid += 1
984
+ if self.is_key_on_cooldown(key):
985
+ continue
986
+ if not self.key_can_send_rpm(key):
987
+ continue
988
+ live += 1
989
+ return live, valid
990
+
991
+ def decay_stale_failures(self, older_than: float = 180.0) -> int:
992
+ """Forget consecutive failures a key has since outlived.
993
+
994
+ Without this a key that had two transient errors five minutes ago
995
+ keeps paying for them: the scheduling cost weighs failures at 8 each,
996
+ so it stays at the back of the queue indefinitely.
997
+ """
998
+ now = time.time()
999
+ decayed = 0
1000
+ for ks in self._key_states.values():
1001
+ if ks.consecutive_failures and now - ks.last_failure_at > older_than:
1002
+ ks.consecutive_failures -= 1
1003
+ decayed += 1
1004
+ return decayed
1005
+
1006
+ def key_cooldown_info(self, key: str) -> tuple[float, str]:
1007
+ """Return ``(remaining_seconds, reason)`` for the dashboard."""
1008
+ if self.is_key_on_cooldown(key):
1009
+ return self.cooldown_remaining(key), self.cooldown_reason(key)
1010
+ return 0.0, ""