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.
- openvidia/__init__.py +30 -0
- openvidia/__main__.py +827 -0
- openvidia/_upstream_utils.py +57 -0
- openvidia/anthropic_shim.py +714 -0
- openvidia/compaction.py +851 -0
- openvidia/config.py +513 -0
- openvidia/embedding_cache.py +69 -0
- openvidia/graph_engine.py +466 -0
- openvidia/provider_fallback.py +98 -0
- openvidia/proxy_app.py +863 -0
- openvidia/proxy_state.py +1010 -0
- openvidia/redis_sync.py +93 -0
- openvidia/responses_shim.py +1374 -0
- openvidia/safe_file.py +100 -0
- openvidia/server_manager.py +106 -0
- openvidia/upstream_router.py +50 -0
- openvidia/web/assets/32x32.ico +0 -0
- openvidia/web/assets/64x64.ico +0 -0
- openvidia/web/assets/dashboard.gif +0 -0
- openvidia/web/assets/favicon.ico +0 -0
- openvidia/web/assets/logo.png +0 -0
- openvidia/web/favicon.ico +0 -0
- openvidia/web/index.html +166 -0
- openvidia/web/logo.png +0 -0
- openvidia/web/main.js +732 -0
- openvidia/web/styles.css +425 -0
- openvidia/webui.py +532 -0
- openvidia-2.2.0.dist-info/METADATA +814 -0
- openvidia-2.2.0.dist-info/RECORD +33 -0
- openvidia-2.2.0.dist-info/WHEEL +5 -0
- openvidia-2.2.0.dist-info/entry_points.txt +2 -0
- openvidia-2.2.0.dist-info/licenses/LICENSE +21 -0
- openvidia-2.2.0.dist-info/top_level.txt +1 -0
openvidia/proxy_state.py
ADDED
|
@@ -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, ""
|