Skip to content

Commit 31b2cf1

Browse files
author
ciroautuori
committed
feat(ui): upstream health badge, model health status and auto-refresh catalog
1 parent 43636de commit 31b2cf1

6 files changed

Lines changed: 165 additions & 3 deletions

File tree

‎openvidia/proxy_app.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,63 @@ async def _background_health_check(state: ProxyState, client: httpx.AsyncClient)
171171
pass
172172

173173

174+
# ── Upstream (NVIDIA) probe ────────────────────────────────────────────
175+
# The dashboard needs one unambiguous signal that answers "is it us or is it
176+
# NVIDIA?" A cheap GET /v1/models on one healthy key — short timeout, no RPM
177+
# budget spent on chat — is enough to tell the two apart. Results land in
178+
# state.upstream so /api/status and the web UI can render them.
179+
180+
_UPSTREAM_PROBE_INTERVAL = 30.0
181+
_UPSTREAM_PROBE_TIMEOUT = 8.0
182+
_UPSTREAM_SLOW_MS = 1500.0
183+
184+
185+
async def _probe_upstream(state: ProxyState, client: httpx.AsyncClient) -> None:
186+
"""One reachability probe of the NVIDIA endpoint."""
187+
keys = [k for k in state.keys if state.is_key_healthy(k) and state.key_can_send_rpm(k)]
188+
if not keys:
189+
state.set_upstream_status("unknown", 0.0, "no healthy key to probe with")
190+
return
191+
key = keys[0]
192+
headers = {"Authorization": f"Bearer {key}", "User-Agent": "openvidia/2.0"}
193+
t0 = time.monotonic()
194+
try:
195+
resp = await client.request(
196+
"GET",
197+
UPSTREAM_BASE + "models",
198+
headers=headers,
199+
timeout=httpx.Timeout(_UPSTREAM_PROBE_TIMEOUT),
200+
)
201+
await resp.aclose()
202+
latency = (time.monotonic() - t0) * 1000
203+
if resp.is_success:
204+
status = "slow" if latency > _UPSTREAM_SLOW_MS else "ok"
205+
detail = "" if status == "ok" else f"responded in {latency:.0f}ms"
206+
state.set_upstream_status(status, latency, detail)
207+
else:
208+
state.set_upstream_status(
209+
"down",
210+
latency,
211+
f"upstream answered {resp.status_code}",
212+
)
213+
except httpx.TimeoutException:
214+
state.set_upstream_status(
215+
"down", (time.monotonic() - t0) * 1000, "upstream did not answer within 8s"
216+
)
217+
except httpx.HTTPError as e:
218+
state.set_upstream_status("down", (time.monotonic() - t0) * 1000, f"network error: {e}")
219+
220+
221+
async def _background_upstream_probe(state: ProxyState, client: httpx.AsyncClient) -> None:
222+
"""Every 30s, measure whether NVIDIA itself is reachable."""
223+
try:
224+
while True:
225+
await _probe_upstream(state, client)
226+
await asyncio.sleep(_UPSTREAM_PROBE_INTERVAL)
227+
except asyncio.CancelledError:
228+
pass
229+
230+
174231
class BodyLimitMiddleware(BaseHTTPMiddleware):
175232
async def dispatch(self, request: Request, call_next):
176233
cl = request.headers.get("content-length")
@@ -279,12 +336,15 @@ async def _pre_warm():
279336

280337
asyncio.create_task(_pre_warm())
281338
state.health_task = asyncio.create_task(_background_health_check(state, client))
339+
state.upstream_task = asyncio.create_task(_background_upstream_probe(state, client))
282340
if rs is not None:
283341
rs.on_remote = state.apply_remote_event
284342
await rs.start()
285343
yield
286344
if state.health_task is not None:
287345
state.health_task.cancel()
346+
if state.upstream_task is not None:
347+
state.upstream_task.cancel()
288348
if rs is not None:
289349
await rs.close()
290350
await client.aclose()
@@ -366,6 +426,15 @@ async def models_handler():
366426
m["slug"] = m.get("id", "")
367427
m["display_name"] = m.get("id", "")
368428
data["models"] = models
429+
# Arricchisci ogni modello con la salute appresa dal
430+
# traffico reale (ModelHealth): la dashboard la usa per
431+
# marcare i modelli con circuito aperto o latenza alta
432+
# senza dover fare un secondo giro di API.
433+
for m in list(data.get("data") or []) + list(data.get("models") or []):
434+
mid = m.get("id", "")
435+
h = state.model_health.get(mid)
436+
if h is not None:
437+
m["health"] = h.as_dict()
369438
# Inietta l'alias "openvidia" in cima a entrambe le liste
370439
# così i picker dei CLI (Codex, opencode) lo mostrano come
371440
# opzione selezionabile. Il proxy lo risolve a runtime nel

‎openvidia/proxy_state.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ def __init__(
382382
self.active_model: str | None = None
383383
self.running: bool = True
384384
self.health_task: asyncio.Task | None = None
385+
self.upstream_task: asyncio.Task | None = None
385386

386387
self.cooldowns: dict[str, KeyCooldown] = {}
387388
self.rpm: dict[str, RpmTracker] = {}
@@ -393,6 +394,16 @@ def __init__(
393394
self.model_health: dict[str, ModelHealth] = {}
394395
# Per-(key, model) health, learned from real traffic (KeyModelHealth).
395396
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+
}
396407
# Optional cross-node synchronisation (Redis). None = single-node.
397408
self.redis_sync = redis_sync
398409

@@ -411,6 +422,20 @@ def __init__(
411422
def keys(self) -> list[str]:
412423
return self._keys
413424

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+
414439
@keys.setter
415440
def keys(self, new_keys: list[str]) -> None:
416441
"""Replace the pool, carrying over state for keys that remain.

‎openvidia/web/index.html‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
<span class="logo-text">OpenVidia</span>
2020
</div>
2121
<div class="header-right">
22+
<span class="header-stat upstream-badge" id="hUpstream" title="NVIDIA upstream health — probed every 30s">
23+
<span class="up-dot" id="upDot"></span> NVIDIA <span id="upText">…</span>
24+
</span>
2225
<span class="header-stat" id="hKeyCount">0 keys</span>
2326
<span class="header-stat" id="hRpm">0 RPM</span>
2427
<button class="theme-btn" id="themeToggle" title="Toggle theme">🌙</button>

‎openvidia/web/main.js‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,29 @@ document.querySelectorAll('[data-toggle]').forEach(hd => {
9595
})
9696

9797
/* ── Running State ──────────────────────────── */
98+
function renderUpstream(up) {
99+
const badge = $('hUpstream')
100+
const dot = $('upDot')
101+
const txt = $('upText')
102+
if (!badge) return
103+
const st = (up && up.status) || 'unknown'
104+
const ms = up && up.latency_ms
105+
badge.className = `header-stat upstream-badge up-${st}`
106+
if (st === 'ok') {
107+
txt.textContent = ms != null ? `${Math.round(ms)}ms` : 'ok'
108+
badge.title = 'NVIDIA reachable'
109+
} else if (st === 'slow') {
110+
txt.textContent = ms != null ? `slow ${Math.round(ms)}ms` : 'slow'
111+
badge.title = `NVIDIA is slow (${(up && up.detail) || 'high latency'}) — not your keys`
112+
} else if (st === 'down') {
113+
txt.textContent = 'down'
114+
badge.title = `NVIDIA is DOWN (${(up && up.detail) || 'no response'}) — your proxy and keys are fine`
115+
} else {
116+
txt.textContent = '…'
117+
badge.title = 'NVIDIA health not measured yet'
118+
}
119+
}
120+
98121
async function updateRunningState() {
99122
try {
100123
const st = await api('GET', '/api/status')
@@ -104,6 +127,7 @@ async function updateRunningState() {
104127
$('stopBtn').style.display = running ? '' : 'none'
105128
$('startBtn').style.display = running ? 'none' : ''
106129
$('hKeyCount').textContent = `${st.keys || 0} keys`
130+
renderUpstream(st.upstream)
107131
} catch (_) {
108132
$('statusText').textContent = 'Offline'
109133
$('statusDot').querySelector('.pulse-dot').className = 'pulse-dot'
@@ -308,11 +332,14 @@ function renderFilters() {
308332
async function fetchModels() {
309333
try {
310334
const r = await fetch('/v1/models'); const d = await r.json()
311-
allModels = (d.data || d.models || []).map(m => ({ id: m.id, owned_by: m.owned_by || '' }))
335+
allModels = (d.data || d.models || []).map(m => ({ id: m.id, owned_by: m.owned_by || '', health: m.health || null }))
312336
$('modelCount').textContent = allModels.length
313337
renderFilters(); renderModelList(); renderUsage()
314338
} catch (_) { $('modelList').innerHTML = '<div class="browser-empty">Failed to load</div>' }
315339
}
340+
// Refresco il catalogo ogni 60s: NVIDIA toglie/rinomina modelli senza preavviso,
341+
// e la lista deve seguire senza dover ricaricare la pagina.
342+
setInterval(fetchModels, 60000)
316343

317344
async function testModel(id, btn) {
318345
btn.className = 'browser-test testing'; btn.textContent = '…'
@@ -341,10 +368,26 @@ function renderModelList() {
341368
f.forEach(m => {
342369
const item = document.createElement('div')
343370
item.className = `browser-item ${m.id === activeModel ? 'active' : ''}`
344-
// m.id / m.owned_by come straight from the upstream /v1/models response.
345-
// Remote data never becomes markup.
371+
// m.id / m.owned_by / m.health come straight from the upstream /v1/models
372+
// response (arricchita dal proxy con ModelHealth). Remote data never
373+
// becomes markup.
346374
const nm = document.createElement('span'); nm.className = 'browser-name'; nm.textContent = m.id
347375
item.appendChild(nm)
376+
// Badge di salute: circuito aperto (modello giù) o failure rate alto
377+
// (modello degradato) → il problema è NVIDIA, non le key.
378+
if (m.health) {
379+
const h = m.health
380+
let cls = '', label = ''
381+
if (h.circuit_open) { cls = 'health-bad'; label = 'down' }
382+
else if (h.failure_rate >= 0.5 && h.requests >= 4) { cls = 'health-bad'; label = 'failing' }
383+
else if (h.median_ttft > 5000 && h.requests >= 4) { cls = 'health-slow'; label = `slow ${Math.round(h.median_ttft / 1000)}s` }
384+
if (cls) {
385+
const hb = document.createElement('span')
386+
hb.className = `health-badge ${cls}`; hb.textContent = label
387+
hb.title = `${h.requests} req · ${Math.round(h.failure_rate * 100)}% fail · median ttft ${Math.round(h.median_ttft)}ms — upstream NVIDIA, not your keys`
388+
item.appendChild(hb)
389+
}
390+
}
348391
if (m.owned_by) {
349392
const ow = document.createElement('span'); ow.className = 'browser-owner'; ow.textContent = m.owned_by
350393
item.appendChild(ow)

‎openvidia/web/styles.css‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,27 @@ header{display:flex;align-items:center;justify-content:space-between;padding:2px
118118
}
119119
.theme-btn:hover{background:var(--hover);color:var(--text);border-color:var(--border-strong)}
120120

121+
/* ── Upstream (NVIDIA) health badge ──────────── */
122+
.upstream-badge{display:inline-flex;align-items:center;gap:6px;cursor:help}
123+
.up-dot{width:7px;height:7px;border-radius:50%;background:var(--text3);flex:none}
124+
.upstream-badge.up-ok{color:var(--green);border-color:color-mix(in srgb,var(--green) 40%,var(--border))}
125+
.upstream-badge.up-ok .up-dot{background:var(--green);box-shadow:0 0 6px color-mix(in srgb,var(--green) 70%,transparent)}
126+
.upstream-badge.up-slow{color:var(--amber);border-color:color-mix(in srgb,var(--amber) 40%,var(--border))}
127+
.upstream-badge.up-slow .up-dot{background:var(--amber);box-shadow:0 0 6px color-mix(in srgb,var(--amber) 70%,transparent)}
128+
.upstream-badge.up-down{color:var(--red);border-color:color-mix(in srgb,var(--red) 40%,var(--border))}
129+
.upstream-badge.up-down .up-dot{background:var(--red);box-shadow:0 0 6px color-mix(in srgb,var(--red) 70%,transparent)}
130+
.upstream-badge.up-down .pulse{animation:pulseRed 1.6s ease-in-out infinite}
131+
@keyframes pulseRed{0%,100%{opacity:1}50%{opacity:.35}}
132+
.upstream-detail{font-size:.62rem;color:var(--text2);margin-left:2px}
133+
134+
/* ── Model health badge (in the model list) ──── */
135+
.health-badge{
136+
font-family:var(--mono);font-size:.55rem;font-weight:700;letter-spacing:.04em;
137+
padding:1px 6px;border-radius:100px;margin-left:6px;flex:none;text-transform:uppercase
138+
}
139+
.health-badge.health-bad{color:#fff;background:var(--red)}
140+
.health-badge.health-slow{color:#1a1a1a;background:var(--amber)}
141+
121142
/* ── Bento Grid ──────────────────────────────── */
122143
.bento{
123144
display:grid;

‎openvidia/webui.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ async def api_status() -> dict:
9797
"port": state.port,
9898
"keys": len(state.keys),
9999
"cooldowns": on_cooldown,
100+
"upstream": dict(state.upstream),
100101
}
101102

102103
@app.get("/api/stats")

0 commit comments

Comments
 (0)