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/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()
|