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/webui.py ADDED
@@ -0,0 +1,532 @@
1
+ """Embedded control panel served by the proxy itself.
2
+
3
+ A thin FastAPI router mounted on the same process that runs the proxy:
4
+ serves the static dashboard bundle plus a small REST/SSE surface for
5
+ key management, stats, presets and live logs. All mutators persist via
6
+ ``config`` so state survives restarts; reads mirror ``ProxyState``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import sys
14
+ import threading
15
+ import time
16
+ import webbrowser
17
+ from pathlib import Path
18
+
19
+ import httpx
20
+ from fastapi import FastAPI, Request
21
+ from fastapi.responses import HTMLResponse, Response, StreamingResponse
22
+
23
+ from . import config
24
+ from .config import UPSTREAM_BASE
25
+ from .proxy_state import ProxyState
26
+
27
+ # --------------------------------------------------------------------------- #
28
+ # Static dashboard bundle
29
+ # --------------------------------------------------------------------------- #
30
+
31
+
32
+ def attach_webui(app: FastAPI, state: ProxyState, web_dir: Path) -> None:
33
+ """Register the dashboard + management API on ``app``.
34
+
35
+ Routes are closures over ``state`` and ``web_dir`` so the single
36
+ ``ProxyState`` instance stays the source of truth for both the proxy
37
+ and the UI.
38
+ """
39
+
40
+ @app.get("/")
41
+ async def index() -> HTMLResponse:
42
+ p = web_dir / "index.html"
43
+ if not p.exists():
44
+ return HTMLResponse("<h1>OpenVidia</h1><p>UI not found</p>", status_code=404)
45
+ return HTMLResponse(p.read_text())
46
+
47
+ @app.get("/styles.css")
48
+ async def styles() -> Response:
49
+ p = web_dir / "styles.css"
50
+ return (
51
+ Response(content=p.read_bytes(), media_type="text/css")
52
+ if p.exists()
53
+ else Response("", status_code=404)
54
+ )
55
+
56
+ @app.get("/main.js")
57
+ async def main_js() -> Response:
58
+ p = web_dir / "main.js"
59
+ return (
60
+ Response(content=p.read_bytes(), media_type="application/javascript")
61
+ if p.exists()
62
+ else Response("", status_code=404)
63
+ )
64
+
65
+ @app.get("/logo.png")
66
+ async def logo() -> Response:
67
+ for p in [web_dir / "logo.png", web_dir / "assets" / "logo.png"]:
68
+ if p.exists():
69
+ return Response(content=p.read_bytes(), media_type="image/png")
70
+ return Response("", status_code=404)
71
+
72
+ @app.get("/favicon.ico")
73
+ async def favicon() -> Response:
74
+ for p in [
75
+ web_dir / "favicon.ico",
76
+ web_dir / "assets" / "favicon.ico",
77
+ web_dir / "assets" / "logo.png",
78
+ ]:
79
+ if p.exists():
80
+ return Response(content=p.read_bytes(), media_type="image/x-icon")
81
+ return Response("", status_code=404)
82
+
83
+ # ----------------------------------------------------------------------- #
84
+ # Health & status
85
+ # ----------------------------------------------------------------------- #
86
+
87
+ @app.get("/health")
88
+ async def health() -> dict:
89
+ return {"status": "ok", "keys": len(state.keys), "port": state.port}
90
+
91
+ @app.get("/api/status")
92
+ async def api_status() -> dict:
93
+ async with state.lock:
94
+ on_cooldown = sum(1 for k in state.keys if state.is_key_on_cooldown(k))
95
+ return {
96
+ "running": state.running,
97
+ "port": state.port,
98
+ "keys": len(state.keys),
99
+ "cooldowns": on_cooldown,
100
+ "upstream": dict(state.upstream),
101
+ }
102
+
103
+ @app.get("/api/stats")
104
+ async def api_stats() -> dict:
105
+ async with state.lock:
106
+ on_cooldown = sum(1 for k in state.keys if state.is_key_on_cooldown(k))
107
+ total_rpm = sum(state.key_rpm(k) for k in state.keys)
108
+ n_healthy = sum(1 for k in state.keys if state.is_key_healthy(k))
109
+ in_flight = sum(
110
+ (state._key_states.get(k).in_flight if state._key_states.get(k) else 0)
111
+ for k in state.keys
112
+ )
113
+ agg_ceiling = sum(
114
+ (state.rpm.get(k).max_rpm if state.rpm.get(k) and state.rpm.get(k).max_rpm else 28)
115
+ for k in state.keys
116
+ )
117
+ return {
118
+ "requests": state.stats.requests,
119
+ "rotations": state.stats.rotations,
120
+ "success": state.stats.success,
121
+ "active_index": state.stats.active_key_index,
122
+ "cooldowns": on_cooldown,
123
+ "total_rpm": total_rpm,
124
+ "healthy": n_healthy,
125
+ "in_flight": in_flight,
126
+ "aggregate_rpm_ceiling": agg_ceiling,
127
+ }
128
+
129
+ # ----------------------------------------------------------------------- #
130
+ # Keys CRUD
131
+ # ----------------------------------------------------------------------- #
132
+
133
+ # The dashboard never holds a usable key. It works entirely in masked
134
+ # strings plus an index, so a bug in the page — or a script that got past
135
+ # the origin guard — has nothing to steal. Copy-to-clipboard is the one
136
+ # operation that genuinely needs cleartext, and it asks for one key at a
137
+ # time through its own route.
138
+
139
+ @app.get("/api/keys")
140
+ async def api_get_keys() -> dict:
141
+ async with state.lock:
142
+ return {"keys": [config.mask_key(k) for k in state.keys]}
143
+
144
+ @app.post("/api/keys/reveal")
145
+ async def api_reveal_key(request: Request) -> dict:
146
+ """Return one key in cleartext, by index, for the copy button."""
147
+ body = await request.json()
148
+ idx = body.get("index")
149
+ if not isinstance(idx, int):
150
+ return {"ok": False, "error": "index required"}
151
+ async with state.lock:
152
+ if not 0 <= idx < len(state.keys):
153
+ return {"ok": False, "error": "index out of range"}
154
+ return {"ok": True, "key": state.keys[idx]}
155
+
156
+ @app.post("/api/keys/add")
157
+ async def api_add_key(request: Request) -> dict:
158
+ body = await request.json()
159
+ key = body.get("key", "")
160
+ if not isinstance(key, str) or not key.strip():
161
+ return {"ok": False, "error": "key required"}
162
+ key = key.strip()
163
+ async with state.lock:
164
+ # Duplicates would each get their own index while sharing a single
165
+ # KeyState, which double-counts in-flight and misattributes errors.
166
+ if key in state.keys:
167
+ return {"ok": False, "error": "key already in pool"}
168
+ state.keys = list(state.keys) + [key]
169
+ keys = list(state.keys)
170
+ config.save_keys_file(keys)
171
+ return {"ok": True, "keys": [config.mask_key(k) for k in keys]}
172
+
173
+ @app.post("/api/keys/remove")
174
+ async def api_remove_key(request: Request) -> dict:
175
+ body = await request.json()
176
+ idx = body.get("index")
177
+ if not isinstance(idx, int):
178
+ return {"ok": False, "error": "index required"}
179
+ async with state.lock:
180
+ if not 0 <= idx < len(state.keys):
181
+ return {"ok": False, "error": "index out of range"}
182
+ state.keys = [k for i, k in enumerate(state.keys) if i != idx]
183
+ keys = list(state.keys)
184
+ config.save_keys_file(keys)
185
+ return {"ok": True, "keys": [config.mask_key(k) for k in keys]}
186
+
187
+ # ----------------------------------------------------------------------- #
188
+ # Per-key live stats (cooldown, RPM, validity, freshness)
189
+ # ----------------------------------------------------------------------- #
190
+
191
+ @app.get("/api/keys/stats")
192
+ async def api_key_stats() -> dict:
193
+ async with state.lock:
194
+ stats: dict[str, dict] = {}
195
+ now = time.time()
196
+ for i, k in enumerate(state.keys):
197
+ u = state.stats.key_usage.get(k)
198
+ entry: dict = {}
199
+ if u:
200
+ entry.update(
201
+ {
202
+ "requests": u.requests,
203
+ "success": u.success,
204
+ "failed": u.failed,
205
+ "last_used": u.last_used,
206
+ "last_error": u.last_error,
207
+ "freshness": "fresh"
208
+ if (now - u.last_used) < 120
209
+ else "stale"
210
+ if u.last_used > 0
211
+ else "unused",
212
+ }
213
+ )
214
+ # Cooldown remaining, current RPM, last probe validity,
215
+ # adaptive RPM ceiling, in-flight count and consecutive failures
216
+ # (used by the dashboard to show adaptive backoff state).
217
+ cd_rem = state.cooldown_remaining(k)
218
+ entry["cooldown"] = round(cd_rem, 1) if cd_rem > 0 else 0
219
+ entry["cooldown_reason"] = state.cooldown_reason(k) if cd_rem > 0 else ""
220
+ entry["rpm"] = state.key_rpm(k)
221
+ tracker = state.rpm.get(k)
222
+ entry["rpm_ceiling"] = tracker.max_rpm if tracker and tracker.max_rpm else 28
223
+ ks = state.key_states.get(k)
224
+ entry["is_valid"] = ks.is_valid if ks else True
225
+ entry["in_flight"] = ks.in_flight if ks else 0
226
+ entry["consecutive_failures"] = ks.consecutive_failures if ks else 0
227
+ stats[str(i)] = entry
228
+ return {
229
+ "active_index": state.stats.active_key_index,
230
+ "key_stats": stats,
231
+ }
232
+
233
+ # ----------------------------------------------------------------------- #
234
+ # Active model
235
+ # ----------------------------------------------------------------------- #
236
+
237
+ @app.get("/api/model")
238
+ async def api_get_model() -> dict:
239
+ return {"model": state.active_model or ""}
240
+
241
+ @app.post("/api/model")
242
+ async def api_set_model(request: Request) -> dict:
243
+ body = await request.json()
244
+ raw = body.get("model", "")
245
+ if not isinstance(raw, str):
246
+ return {"ok": False, "error": "model must be a string"}
247
+ m = raw.strip() or None
248
+ if m is not None and not config.is_valid_model_id(m):
249
+ return {"ok": False, "error": "invalid model id"}
250
+ state.active_model = m
251
+ config.save_active_model(m or "")
252
+ return {"ok": True, "model": m or ""}
253
+
254
+ @app.get("/api/thinking")
255
+ async def api_get_thinking() -> dict:
256
+ opts = config.model_options()
257
+ model = state.active_model or ""
258
+ per = (opts.get("per_model") or {}).get(model, {})
259
+ return {
260
+ "model": model,
261
+ "mode": per.get("thinking") or opts.get("thinking", "auto"),
262
+ "inherited": "thinking" not in per,
263
+ }
264
+
265
+ @app.post("/api/thinking")
266
+ async def api_set_thinking(request: Request) -> dict:
267
+ """Set the reasoning mode for the active model (or globally).
268
+
269
+ A hybrid reasoning model emits nothing until it stops thinking, which
270
+ is the difference between a 2s and a 160s first token — worth a switch
271
+ rather than a config file edit.
272
+ """
273
+ body = await request.json()
274
+ mode = body.get("mode", "auto")
275
+ if mode not in ("auto", "on", "off"):
276
+ return {"ok": False, "error": "mode must be auto, on or off"}
277
+ opts = config.model_options()
278
+ model = body.get("model", state.active_model or "")
279
+ if model:
280
+ per = dict(opts.get("per_model") or {})
281
+ entry = dict(per.get(model) or {})
282
+ if mode == "auto":
283
+ entry.pop("thinking", None)
284
+ else:
285
+ entry["thinking"] = mode
286
+ if entry:
287
+ per[model] = entry
288
+ else:
289
+ per.pop(model, None)
290
+ opts["per_model"] = per
291
+ else:
292
+ opts["thinking"] = mode
293
+ config.save_model_options(opts)
294
+ state.log_cb(f"◆ thinking={mode} for {model or 'all models'}")
295
+ return {"ok": True, "model": model, "mode": mode}
296
+
297
+ @app.get("/api/reasoning-effort")
298
+ async def api_get_effort() -> dict:
299
+ opts = config.model_options()
300
+ model = state.active_model or ""
301
+ per = (opts.get("per_model") or {}).get(model, {})
302
+ return {
303
+ "model": model,
304
+ "effort": per.get("reasoning_effort") or opts.get("reasoning_effort", "auto"),
305
+ "inherited": "reasoning_effort" not in per,
306
+ }
307
+
308
+ @app.post("/api/reasoning-effort")
309
+ async def api_set_effort(request: Request) -> dict:
310
+ """Set reasoning effort: low (fast, no thinking), medium (balanced), high (deep)."""
311
+ body = await request.json()
312
+ effort = body.get("effort", "auto")
313
+ if effort not in ("auto", "low", "medium", "high"):
314
+ return {"ok": False, "error": "effort must be auto, low, medium or high"}
315
+ opts = config.model_options()
316
+ model = body.get("model", state.active_model or "")
317
+ if model:
318
+ per = dict(opts.get("per_model") or {})
319
+ entry = dict(per.get(model) or {})
320
+ if effort == "auto":
321
+ entry.pop("reasoning_effort", None)
322
+ else:
323
+ entry["reasoning_effort"] = effort
324
+ if entry:
325
+ per[model] = entry
326
+ else:
327
+ per.pop(model, None)
328
+ opts["per_model"] = per
329
+ else:
330
+ opts["reasoning_effort"] = effort
331
+ config.save_model_options(opts)
332
+ state.log_cb(f"◆ reasoning_effort={effort} for {model or 'all models'}")
333
+ return {"ok": True, "model": model, "effort": effort}
334
+
335
+ @app.get("/api/fallback")
336
+ async def api_get_fallback() -> dict:
337
+ opts = config.model_options()
338
+ model = state.active_model or ""
339
+ per = (opts.get("per_model") or {}).get(model, {})
340
+ return {
341
+ "model": model,
342
+ "fallback": per.get("fallback") or opts.get("fallback", "off"),
343
+ "inherited": "fallback" not in per,
344
+ }
345
+
346
+ @app.post("/api/fallback")
347
+ async def api_set_fallback(request: Request) -> dict:
348
+ """Set fallback mode: off (never failover), on (always failover), auto (default behavior)."""
349
+ body = await request.json()
350
+ mode = body.get("fallback", "off")
351
+ if mode not in ("off", "on", "auto"):
352
+ return {"ok": False, "error": "fallback must be off, on or auto"}
353
+ opts = config.model_options()
354
+ model = body.get("model", state.active_model or "")
355
+ if model:
356
+ per = dict(opts.get("per_model") or {})
357
+ entry = dict(per.get(model) or {})
358
+ if mode == "auto":
359
+ entry.pop("fallback", None)
360
+ else:
361
+ entry["fallback"] = mode
362
+ if entry:
363
+ per[model] = entry
364
+ else:
365
+ per.pop(model, None)
366
+ opts["per_model"] = per
367
+ else:
368
+ opts["fallback"] = mode
369
+ config.save_model_options(opts)
370
+ state.log_cb(f"◆ fallback={mode} for {model or 'all models'}")
371
+ return {"ok": True, "model": model, "fallback": mode}
372
+
373
+ @app.get("/api/model-health")
374
+ async def api_model_health() -> dict:
375
+ """What the proxy has learned about each model from real traffic."""
376
+ return {m: h.as_dict() for m, h in sorted(state.model_health.items())}
377
+
378
+ # ----------------------------------------------------------------------- #
379
+ # Lifecycle: stop / start / restart
380
+ # ----------------------------------------------------------------------- #
381
+
382
+ @app.post("/api/stop")
383
+ async def api_stop() -> dict:
384
+ state.running = False
385
+ return {"ok": True, "status": "stopped"}
386
+
387
+ @app.post("/api/start")
388
+ async def api_start() -> dict:
389
+ state.running = True
390
+ return {"ok": True, "status": "running"}
391
+
392
+ @app.post("/api/restart")
393
+ async def api_restart() -> dict:
394
+ import os as _os
395
+ import signal as _signal
396
+ import subprocess as _sp
397
+
398
+ # Spawn the replacement process first, then kill the current one so
399
+ # there is no window where no proxy is listening. SIGKILL is POSIX
400
+ # only: on Windows os.kill maps SIGTERM to TerminateProcess anyway.
401
+ _kill_sig = _signal.SIGKILL if sys.platform != "win32" else _signal.SIGTERM
402
+
403
+ def _do_restart() -> None:
404
+ import time as _time
405
+
406
+ _time.sleep(0.3)
407
+ _sp.Popen([sys.executable, "-m", "openvidia"] + sys.argv[1:])
408
+ _time.sleep(0.5)
409
+ _os.kill(_os.getpid(), _kill_sig)
410
+
411
+ threading.Thread(target=_do_restart, daemon=True).start()
412
+ return {"ok": True, "restarting": True}
413
+
414
+ # ----------------------------------------------------------------------- #
415
+ # Model probe — fire a 5-token chat completion against upstream
416
+ # ----------------------------------------------------------------------- #
417
+
418
+ @app.post("/api/test-model")
419
+ async def api_test_model(request: Request) -> dict:
420
+ body = await request.json()
421
+ model_id = body.get("model", "")
422
+ if not model_id:
423
+ return {"ok": False, "error": "no model specified"}
424
+
425
+ async with state.lock:
426
+ keys = list(state.keys)
427
+ if not keys:
428
+ return {"ok": False, "error": "no keys"}
429
+
430
+ payload = {
431
+ "model": model_id,
432
+ "messages": [{"role": "user", "content": "ok"}],
433
+ "max_completion_tokens": 5,
434
+ }
435
+ async with httpx.AsyncClient(timeout=httpx.Timeout(15)) as client:
436
+ for key in keys:
437
+ try:
438
+ r = await client.post(
439
+ UPSTREAM_BASE + "chat/completions",
440
+ headers={
441
+ "Authorization": f"Bearer {key}",
442
+ "Content-Type": "application/json",
443
+ },
444
+ json=payload,
445
+ )
446
+ if r.is_success:
447
+ d = r.json()
448
+ txt = d.get("choices", [{}])[0].get("message", {}).get("content", "")
449
+ return {"ok": True, "model": model_id, "response": txt[:100]}
450
+ # 400/429 are key/model-level rejections worth surfacing;
451
+ # everything else is treated as transient and skipped.
452
+ if r.status_code != 400 and r.status_code != 429:
453
+ continue
454
+ except httpx.HTTPError:
455
+ continue
456
+ return {
457
+ "ok": False,
458
+ "model": model_id,
459
+ "error": "unavailable",
460
+ "detail": r.text[:200],
461
+ }
462
+
463
+ return {"ok": False, "model": model_id, "error": "all keys failed"}
464
+
465
+ # ----------------------------------------------------------------------- #
466
+ # Presets (saved key sets)
467
+ # ----------------------------------------------------------------------- #
468
+
469
+ @app.get("/api/presets")
470
+ async def api_get_presets() -> dict:
471
+ return {"presets": config.load_saved_presets()}
472
+
473
+ @app.post("/api/presets")
474
+ async def api_save_presets(request: Request) -> dict:
475
+ body = await request.json()
476
+ raw = body.get("presets", [])
477
+ if not isinstance(raw, list):
478
+ return {"ok": False, "error": "presets must be a list"}
479
+ # Same rule as the active model: these ids are persisted and rendered.
480
+ presets = [m for m in raw if isinstance(m, str) and config.is_valid_model_id(m)]
481
+ if len(presets) != len(raw):
482
+ return {"ok": False, "error": "invalid model id in presets"}
483
+ config.save_presets_file(presets)
484
+ return {"ok": True}
485
+
486
+ # ----------------------------------------------------------------------- #
487
+ # Live log stream (SSE)
488
+ # ----------------------------------------------------------------------- #
489
+
490
+ @app.get("/api/logs/stream")
491
+ async def log_stream(request: Request) -> StreamingResponse:
492
+ async def event_generator():
493
+ q: asyncio.Queue[str] = asyncio.Queue()
494
+
495
+ # Replay the current buffer first so a reconnect doesn't lose
496
+ # the recent history visible before the connection opened.
497
+ buf = list(state.log_buffer)
498
+ for msg in buf:
499
+ yield f"data: {json.dumps({'msg': msg})}\n\n"
500
+
501
+ # Register for live push (no polling) and stream until the
502
+ # client disconnects; heartbeats keep the socket warm during
503
+ # quiet periods without violating the SSE contract.
504
+ state.listeners.add(q)
505
+ try:
506
+ while True:
507
+ if await request.is_disconnected():
508
+ break
509
+ try:
510
+ msg = await asyncio.wait_for(q.get(), timeout=2.0)
511
+ yield f"data: {json.dumps({'msg': msg})}\n\n"
512
+ except TimeoutError:
513
+ yield ": heartbeat\n\n"
514
+ finally:
515
+ state.listeners.discard(q)
516
+
517
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
518
+
519
+
520
+ # --------------------------------------------------------------------------- #
521
+ # Convenience: open the dashboard in the user's default browser
522
+ # --------------------------------------------------------------------------- #
523
+
524
+
525
+ def auto_open(port: int = 1919) -> None:
526
+ """Best-effort browser launch 1.5 s after call.
527
+
528
+ The delay gives the proxy a head start so the browser doesn't race
529
+ the listener and show a connection-refused page on slow machines.
530
+ """
531
+ url = f"http://localhost:{port}/?token={config.control_token()}"
532
+ threading.Timer(1.5, lambda: webbrowser.open(url)).start()