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_app.py
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
"""Reverse proxy core: catch-all route, key rotation, streaming passthrough."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
15
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
16
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
17
|
+
|
|
18
|
+
from . import config
|
|
19
|
+
from .anthropic_shim import handle_anthropic_messages
|
|
20
|
+
from .config import UPSTREAM_BASE
|
|
21
|
+
from .embedding_cache import EmbeddingCache
|
|
22
|
+
from .provider_fallback import load_provider_configs, try_fallback
|
|
23
|
+
from .proxy_state import ProxyState
|
|
24
|
+
from .redis_sync import RedisSync
|
|
25
|
+
from .responses_shim import (
|
|
26
|
+
_MAX_ROTATE_ATTEMPTS,
|
|
27
|
+
_MIN_LIVE_FRACTION,
|
|
28
|
+
_MODEL_PROBE_TIMEOUT,
|
|
29
|
+
_ROTATE_SEND_TIMEOUT,
|
|
30
|
+
_live_pool_snapshot,
|
|
31
|
+
_rotation_phase,
|
|
32
|
+
_upstream_error_message,
|
|
33
|
+
handle_responses,
|
|
34
|
+
)
|
|
35
|
+
from .upstream_router import EndpointRouter
|
|
36
|
+
|
|
37
|
+
MAX_BODY_BYTES = 64 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def default_model(state: ProxyState | None = None) -> str:
|
|
41
|
+
"""The model a request runs on when the client sends the ``openvidia`` alias.
|
|
42
|
+
|
|
43
|
+
Resolved live, never hardcoded: a pinned model name is a liability the day
|
|
44
|
+
the provider retires it or ships something better, and it silently
|
|
45
|
+
overrides what the user picked in the dashboard. Order: the active
|
|
46
|
+
selection, then the first starred preset. Empty means the user has not
|
|
47
|
+
chosen a model yet, and the caller must say so rather than invent one.
|
|
48
|
+
"""
|
|
49
|
+
if state is not None and state.active_model:
|
|
50
|
+
return state.active_model
|
|
51
|
+
try:
|
|
52
|
+
presets = config.load_saved_presets()
|
|
53
|
+
except Exception: # noqa: BLE001 — model choice must never break a request
|
|
54
|
+
presets = []
|
|
55
|
+
return presets[0] if presets else ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _key_model_score(state: ProxyState, key: str) -> float | None:
|
|
59
|
+
"""Mean of the per-model scores observed for this key."""
|
|
60
|
+
scores = [h.score() for (k, _model), h in state.key_model_health.items() if k == key]
|
|
61
|
+
if not scores:
|
|
62
|
+
return None
|
|
63
|
+
return round(sum(scores) / len(scores), 2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
STRIPPED_RESPONSE_HEADERS = {
|
|
67
|
+
"content-encoding",
|
|
68
|
+
"transfer-encoding",
|
|
69
|
+
"content-length",
|
|
70
|
+
"connection",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _check_key_health(
|
|
75
|
+
client: httpx.AsyncClient, key: str, sem: asyncio.Semaphore | None = None
|
|
76
|
+
) -> bool:
|
|
77
|
+
headers = {"Authorization": f"Bearer {key}", "User-Agent": "openvidia/2.0"}
|
|
78
|
+
try:
|
|
79
|
+
if sem is not None:
|
|
80
|
+
async with sem:
|
|
81
|
+
req = client.build_request("GET", UPSTREAM_BASE + "models", headers=headers)
|
|
82
|
+
resp = await client.send(req)
|
|
83
|
+
else:
|
|
84
|
+
req = client.build_request("GET", UPSTREAM_BASE + "models", headers=headers)
|
|
85
|
+
resp = await client.send(req)
|
|
86
|
+
ok = resp.is_success
|
|
87
|
+
await resp.aclose()
|
|
88
|
+
return ok
|
|
89
|
+
except httpx.HTTPError:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _health_check_all(
|
|
94
|
+
state: ProxyState, client: httpx.AsyncClient, force: bool = False
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Probe cooldown-expired keys in parallel with concurrency bounds.
|
|
97
|
+
|
|
98
|
+
Serial probing was fine for <5 keys but stalls pre-warm beyond ~2s when
|
|
99
|
+
many keys are dead. We batch them with asyncio.gather bounded by a
|
|
100
|
+
Semaphore to prevent blasting dozens of requests at once.
|
|
101
|
+
"""
|
|
102
|
+
targets: list[str] = []
|
|
103
|
+
for key in state.keys:
|
|
104
|
+
if not force and not state.is_key_on_cooldown(key):
|
|
105
|
+
continue
|
|
106
|
+
# A rate limit is not something GET /v1/models can disprove. That
|
|
107
|
+
# endpoint serves metadata and answers 200 for a key whose
|
|
108
|
+
# chat/completions quota is spent, so probing a 429 cooldown only ever
|
|
109
|
+
# ends it early — and the key goes straight back to being rate-limited.
|
|
110
|
+
# Let 429 cooldowns expire on their own schedule.
|
|
111
|
+
if not force and state.cooldown_status(key) == 429:
|
|
112
|
+
continue
|
|
113
|
+
# A 403/401 means the key is dead (invalid auth). GET /v1/models may
|
|
114
|
+
# still return 200 for some dead keys (NVIDIA's auth check is
|
|
115
|
+
# inconsistent across endpoints), so probing would revive a key that
|
|
116
|
+
# will just 403 again on chat/completions. Let the 3600s cooldown
|
|
117
|
+
# expire naturally.
|
|
118
|
+
if not force and state.cooldown_status(key) in (401, 403):
|
|
119
|
+
continue
|
|
120
|
+
# Skip keys with most of their cooldown left — probe only when nearing expiry.
|
|
121
|
+
if not force and state.cooldown_remaining(key) > 30:
|
|
122
|
+
continue
|
|
123
|
+
targets.append(key)
|
|
124
|
+
|
|
125
|
+
if not targets:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
sem = asyncio.Semaphore(5)
|
|
129
|
+
results = await asyncio.gather(
|
|
130
|
+
*(_check_key_health(client, k, sem) for k in targets),
|
|
131
|
+
return_exceptions=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
revived = 0
|
|
135
|
+
for key, healthy in zip(targets, results, strict=True):
|
|
136
|
+
if isinstance(healthy, Exception):
|
|
137
|
+
healthy = False
|
|
138
|
+
if healthy:
|
|
139
|
+
# Reachability restored, throughput unproven: clear the cooldown but
|
|
140
|
+
# leave the adaptive RPM ceiling where the pool learned to put it.
|
|
141
|
+
state.restore_key(key, rehab_rpm=False)
|
|
142
|
+
revived += 1
|
|
143
|
+
elif force:
|
|
144
|
+
state.mark_key_failed(key)
|
|
145
|
+
n_unhealthy = sum(1 for k in state.keys if state.is_key_on_cooldown(k))
|
|
146
|
+
all_ok = len(state.keys) - n_unhealthy
|
|
147
|
+
state.log_cb(
|
|
148
|
+
f"⚕ health: {all_ok}/{len(state.keys)} OK"
|
|
149
|
+
+ (f", {n_unhealthy} on cooldown" if n_unhealthy else "")
|
|
150
|
+
+ (f", {revived} revived" if revived else "")
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _background_health_check(state: ProxyState, client: httpx.AsyncClient) -> None:
|
|
155
|
+
"""Once a minute: age out stale failures, then probe expiring cooldowns.
|
|
156
|
+
|
|
157
|
+
The failure decay used to be a second task on its own 60s timer, reaching
|
|
158
|
+
into state._key_states from outside the class to decrement a counter. It
|
|
159
|
+
runs on the same schedule as the health check and belongs with it.
|
|
160
|
+
|
|
161
|
+
Note what neither of them does: ping healthy keys. That would spend ~25
|
|
162
|
+
requests a minute across the pool, inflate every key's RPM window, and
|
|
163
|
+
manufacture the 429s this whole module exists to avoid.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
while True:
|
|
167
|
+
await asyncio.sleep(60)
|
|
168
|
+
state.decay_stale_failures()
|
|
169
|
+
await _health_check_all(state, client)
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
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
|
+
|
|
231
|
+
class BodyLimitMiddleware(BaseHTTPMiddleware):
|
|
232
|
+
async def dispatch(self, request: Request, call_next):
|
|
233
|
+
cl = request.headers.get("content-length")
|
|
234
|
+
if cl is not None:
|
|
235
|
+
try:
|
|
236
|
+
if int(cl) > MAX_BODY_BYTES:
|
|
237
|
+
return JSONResponse({"error": "payload too large"}, status_code=413)
|
|
238
|
+
except ValueError:
|
|
239
|
+
pass
|
|
240
|
+
return await call_next(request)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Origins the dashboard itself can be served from. Anything else asking for
|
|
244
|
+
# /api/* or /ops/* is a foreign page using the user's browser as a deputy.
|
|
245
|
+
LOCAL_ORIGINS = frozenset(f"http://{host}:1919" for host in ("localhost", "127.0.0.1", "[::1]"))
|
|
246
|
+
|
|
247
|
+
# Host values that legitimately reach a loopback-bound server. A request whose
|
|
248
|
+
# Host is a public name resolving to 127.0.0.1 is DNS rebinding, not a user.
|
|
249
|
+
LOCAL_HOSTS = frozenset(
|
|
250
|
+
["localhost", "127.0.0.1", "::1", "[::1]"]
|
|
251
|
+
+ [f"{h}:1919" for h in ("localhost", "127.0.0.1", "[::1]")]
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
GUARDED_PREFIXES = ("/api", "/ops")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
TOKEN_HEADER = "x-openvidia-token"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class LocalOnlyMiddleware(BaseHTTPMiddleware):
|
|
261
|
+
"""Authenticate the control plane, and stop browsers from riding along.
|
|
262
|
+
|
|
263
|
+
Two independent checks, because they defend against different attackers:
|
|
264
|
+
|
|
265
|
+
* **A token** (``config.control_token``) — the actual authentication.
|
|
266
|
+
Holding it proves the caller could read a 0600 file owned by this user.
|
|
267
|
+
This is what stops a script: header checks alone did not, because any
|
|
268
|
+
non-browser client can send ``Host: localhost:1919`` and walk straight
|
|
269
|
+
in. That was reachable in practice — a ``tailscale serve`` forward on
|
|
270
|
+
this port let every node on the tailnet reveal keys in cleartext and
|
|
271
|
+
stop the proxy. Loopback binding is no defence there either: the
|
|
272
|
+
forwarder connects from 127.0.0.1 on the peer's behalf, so the source
|
|
273
|
+
address says nothing.
|
|
274
|
+
|
|
275
|
+
* **Origin / Host** — kept as defence in depth against the browser. A page
|
|
276
|
+
cannot forge either, so even a token that leaked into a URL the user
|
|
277
|
+
pasted somewhere cannot be replayed cross-origin.
|
|
278
|
+
|
|
279
|
+
/v1/* is deliberately unauthenticated: it is the proxy's whole purpose,
|
|
280
|
+
every local client would need the token, and it hands out no secrets.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(self, app, token: str):
|
|
284
|
+
super().__init__(app)
|
|
285
|
+
self._token = token
|
|
286
|
+
|
|
287
|
+
async def dispatch(self, request: Request, call_next):
|
|
288
|
+
path = request.url.path
|
|
289
|
+
if path.startswith(GUARDED_PREFIXES):
|
|
290
|
+
origin = request.headers.get("origin")
|
|
291
|
+
if origin is not None and origin not in LOCAL_ORIGINS:
|
|
292
|
+
return JSONResponse({"error": "cross-origin request denied"}, status_code=403)
|
|
293
|
+
host = (request.headers.get("host") or "").lower()
|
|
294
|
+
if host and host not in LOCAL_HOSTS:
|
|
295
|
+
return JSONResponse({"error": "invalid host header"}, status_code=403)
|
|
296
|
+
|
|
297
|
+
supplied = request.headers.get(TOKEN_HEADER) or request.query_params.get("token") or ""
|
|
298
|
+
# compare_digest, not ==: string comparison returns early on the
|
|
299
|
+
# first differing byte, which leaks the prefix to a timing attack.
|
|
300
|
+
if not secrets.compare_digest(supplied, self._token):
|
|
301
|
+
return JSONResponse(
|
|
302
|
+
{
|
|
303
|
+
"error": "control plane requires a token",
|
|
304
|
+
"hint": f"send it as {TOKEN_HEADER} or ?token=; "
|
|
305
|
+
f"it lives in {config.control_token_path()}",
|
|
306
|
+
},
|
|
307
|
+
status_code=401,
|
|
308
|
+
)
|
|
309
|
+
return await call_next(request)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def create_app(state: ProxyState, web_dir: Path | None = None) -> FastAPI:
|
|
313
|
+
limits = httpx.Limits(max_keepalive_connections=100, max_connections=200, keepalive_expiry=30.0)
|
|
314
|
+
proxy_url = config.outbound_proxy()
|
|
315
|
+
client = httpx.AsyncClient(
|
|
316
|
+
http2=config.upstream_http2(),
|
|
317
|
+
proxy=proxy_url if proxy_url else None,
|
|
318
|
+
limits=limits,
|
|
319
|
+
timeout=httpx.Timeout(**config.httpx_timeout_kwargs()),
|
|
320
|
+
)
|
|
321
|
+
router = EndpointRouter(config.upstream_endpoints())
|
|
322
|
+
emb_cache = EmbeddingCache()
|
|
323
|
+
_redis_url = config.redis_url()
|
|
324
|
+
rs = RedisSync(_redis_url) if _redis_url else None
|
|
325
|
+
if rs is not None:
|
|
326
|
+
state.redis_sync = rs
|
|
327
|
+
|
|
328
|
+
@asynccontextmanager
|
|
329
|
+
async def lifespan(app: FastAPI):
|
|
330
|
+
async def _pre_warm():
|
|
331
|
+
state.log_cb("⚕ pre-warm: checking all keys...")
|
|
332
|
+
await _health_check_all(state, client, force=True)
|
|
333
|
+
state.log_cb(
|
|
334
|
+
f"⚕ pre-warm done ({sum(1 for k in state.keys if state.is_key_healthy(k))}/{len(state.keys)} healthy)"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
asyncio.create_task(_pre_warm())
|
|
338
|
+
state.health_task = asyncio.create_task(_background_health_check(state, client))
|
|
339
|
+
state.upstream_task = asyncio.create_task(_background_upstream_probe(state, client))
|
|
340
|
+
if rs is not None:
|
|
341
|
+
rs.on_remote = state.apply_remote_event
|
|
342
|
+
await rs.start()
|
|
343
|
+
yield
|
|
344
|
+
if state.health_task is not None:
|
|
345
|
+
state.health_task.cancel()
|
|
346
|
+
if state.upstream_task is not None:
|
|
347
|
+
state.upstream_task.cancel()
|
|
348
|
+
if rs is not None:
|
|
349
|
+
await rs.close()
|
|
350
|
+
await client.aclose()
|
|
351
|
+
|
|
352
|
+
app = FastAPI(lifespan=lifespan)
|
|
353
|
+
|
|
354
|
+
# Not a wildcard. The dashboard is same-origin with the proxy, so it never
|
|
355
|
+
# needs CORS at all; the only reason to answer any origin is to let a
|
|
356
|
+
# foreign page read the response — which is exactly the thing to prevent
|
|
357
|
+
# when one of the responses is the user's API keys.
|
|
358
|
+
app.add_middleware(
|
|
359
|
+
CORSMiddleware,
|
|
360
|
+
allow_origins=sorted(LOCAL_ORIGINS),
|
|
361
|
+
allow_methods=["*"],
|
|
362
|
+
allow_headers=["*"],
|
|
363
|
+
)
|
|
364
|
+
app.add_middleware(LocalOnlyMiddleware, token=config.control_token())
|
|
365
|
+
app.add_middleware(BodyLimitMiddleware)
|
|
366
|
+
|
|
367
|
+
if web_dir and web_dir.exists():
|
|
368
|
+
from .webui import attach_webui
|
|
369
|
+
|
|
370
|
+
attach_webui(app, state, web_dir)
|
|
371
|
+
|
|
372
|
+
# ── Responses API shim → chat/completions (Codex) ──────────────────
|
|
373
|
+
@app.post("/v1/responses")
|
|
374
|
+
async def responses_handler(request: Request):
|
|
375
|
+
if not state.running:
|
|
376
|
+
return JSONResponse({"error": "proxy stopped"}, status_code=503)
|
|
377
|
+
state.stats.requests += 1
|
|
378
|
+
return await handle_responses(request, state, client)
|
|
379
|
+
|
|
380
|
+
# ── Anthropic Messages API shim (Claude Code) ───────────────────────
|
|
381
|
+
# Separate endpoint — only active if the user points ANTHROPIC_BASE_URL
|
|
382
|
+
# at localhost:1919. Zero impact on the default Claude Code flow.
|
|
383
|
+
@app.post("/v1/messages")
|
|
384
|
+
async def anthropic_messages_handler(request: Request):
|
|
385
|
+
if not state.running:
|
|
386
|
+
return JSONResponse(
|
|
387
|
+
{
|
|
388
|
+
"type": "error",
|
|
389
|
+
"error": {"type": "api_error", "message": "proxy stopped"},
|
|
390
|
+
},
|
|
391
|
+
status_code=503,
|
|
392
|
+
)
|
|
393
|
+
state.stats.requests += 1
|
|
394
|
+
return await handle_anthropic_messages(request, state, client)
|
|
395
|
+
|
|
396
|
+
# ── /v1/models in OpenAI format (Codex compatibility) ──────────────
|
|
397
|
+
@app.get("/v1/models")
|
|
398
|
+
async def models_handler():
|
|
399
|
+
if not state.running:
|
|
400
|
+
return JSONResponse({"error": "proxy stopped"}, status_code=503)
|
|
401
|
+
|
|
402
|
+
async with state.lock:
|
|
403
|
+
keys = list(state.keys)
|
|
404
|
+
if not keys:
|
|
405
|
+
return JSONResponse({"error": "no keys"}, status_code=503)
|
|
406
|
+
|
|
407
|
+
# The alternative endpoints are fallbacks: try them in order until the
|
|
408
|
+
# first one answers with the model list.
|
|
409
|
+
for endpoint in router.healthy_endpoints():
|
|
410
|
+
for key in keys:
|
|
411
|
+
if not state.is_key_healthy(key) or not state.key_can_send_rpm(key):
|
|
412
|
+
continue
|
|
413
|
+
headers = {"Authorization": f"Bearer {key}", "User-Agent": "openvidia/2.0"}
|
|
414
|
+
try:
|
|
415
|
+
req = client.build_request("GET", endpoint + "models", headers=headers)
|
|
416
|
+
resp = await client.send(req)
|
|
417
|
+
if resp.is_success:
|
|
418
|
+
router.mark_success(endpoint + "models")
|
|
419
|
+
data = resp.json()
|
|
420
|
+
await resp.aclose()
|
|
421
|
+
# Keep both keys: "data" (OpenAI standard, used by
|
|
422
|
+
# Codex) and "models" (used by other clients).
|
|
423
|
+
if "data" in data and "models" not in data:
|
|
424
|
+
models = list(data["data"])
|
|
425
|
+
for m in models:
|
|
426
|
+
m["slug"] = m.get("id", "")
|
|
427
|
+
m["display_name"] = m.get("id", "")
|
|
428
|
+
data["models"] = models
|
|
429
|
+
# Enrich each model with the health learned from real
|
|
430
|
+
# traffic (ModelHealth): the dashboard uses it to flag
|
|
431
|
+
# models with an open circuit or high latency without
|
|
432
|
+
# needing a second round of API calls.
|
|
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()
|
|
438
|
+
# Inject the "openvidia" alias at the top of both lists
|
|
439
|
+
# so the CLI pickers (Codex, opencode) show it as a
|
|
440
|
+
# selectable option. The proxy resolves it at runtime to
|
|
441
|
+
# the model selected in the dashboard.
|
|
442
|
+
alias = {
|
|
443
|
+
"id": "openvidia",
|
|
444
|
+
"object": "model",
|
|
445
|
+
"slug": "openvidia",
|
|
446
|
+
"display_name": "OpenVidia (dashboard auto-select)",
|
|
447
|
+
}
|
|
448
|
+
if isinstance(data.get("models"), list):
|
|
449
|
+
data["models"].insert(0, alias)
|
|
450
|
+
if isinstance(data.get("data"), list):
|
|
451
|
+
data["data"].insert(0, dict(alias))
|
|
452
|
+
return JSONResponse(data)
|
|
453
|
+
await resp.aclose()
|
|
454
|
+
except httpx.HTTPError:
|
|
455
|
+
continue
|
|
456
|
+
return JSONResponse({"error": "all keys failed"}, status_code=503)
|
|
457
|
+
|
|
458
|
+
# ── Internal ops endpoint: not proxied, dashboard-facing ──────────
|
|
459
|
+
# Exposes live per-key health/RPM/in-flight/consecutive-failures so the
|
|
460
|
+
# dashboard can render the whole pool, not just an aggregate count.
|
|
461
|
+
@app.get("/ops/keys")
|
|
462
|
+
async def _ops_keys_inner() -> JSONResponse:
|
|
463
|
+
if not state.running:
|
|
464
|
+
return JSONResponse({"error": "proxy stopped"}, status_code=503)
|
|
465
|
+
async with state.lock:
|
|
466
|
+
out: list[dict] = []
|
|
467
|
+
for idx, key in enumerate(state.keys):
|
|
468
|
+
ks = state._key_states.get(key)
|
|
469
|
+
redacted = key[:5] + "…" + key[-4:] if len(key) > 12 else "***"
|
|
470
|
+
tracker = state.rpm.get(key)
|
|
471
|
+
ku = state.stats.key_usage.get(key)
|
|
472
|
+
out.append(
|
|
473
|
+
{
|
|
474
|
+
"index": idx,
|
|
475
|
+
"key": redacted,
|
|
476
|
+
"valid": bool(ks and ks.is_valid),
|
|
477
|
+
"healthy": state.is_key_healthy(key),
|
|
478
|
+
"cooldown_remaining": round(state.cooldown_remaining(key), 1),
|
|
479
|
+
"cooldown_reason": state.cooldown_reason(key),
|
|
480
|
+
"rpm": state.key_rpm(key),
|
|
481
|
+
"rpm_ceiling": tracker.max_rpm if tracker and tracker.max_rpm else None,
|
|
482
|
+
"observed_ceiling": tracker.observed_ceiling
|
|
483
|
+
if tracker and tracker.observed_ceiling
|
|
484
|
+
else None,
|
|
485
|
+
"model_score": _key_model_score(state, key),
|
|
486
|
+
"in_flight": ks.in_flight if ks else 0,
|
|
487
|
+
"consecutive_failures": ks.consecutive_failures if ks else 0,
|
|
488
|
+
"requests": ku.requests if ku else 0,
|
|
489
|
+
"success": ku.success if ku else 0,
|
|
490
|
+
"failed": ku.failed if ku else 0,
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
return JSONResponse(
|
|
494
|
+
{
|
|
495
|
+
"keys": out,
|
|
496
|
+
"n_keys": len(state.keys),
|
|
497
|
+
"n_healthy": sum(1 for k in state.keys if state.is_key_healthy(k)),
|
|
498
|
+
"n_on_cooldown": sum(1 for k in state.keys if state.is_key_on_cooldown(k)),
|
|
499
|
+
"aggregate_rpm": sum(state.key_rpm(k) for k in state.keys),
|
|
500
|
+
"aggregate_rpm_ceiling": len(state.keys) * 28,
|
|
501
|
+
"aggregate_observed_ceiling": sum(
|
|
502
|
+
t.observed_ceiling for t in state.rpm.values() if t.observed_ceiling
|
|
503
|
+
),
|
|
504
|
+
"active_index": state.stats.active_key_index,
|
|
505
|
+
}
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# ── Catch-all proxy → NVIDIA NIM ──────────────────────────────────
|
|
509
|
+
@app.api_route(
|
|
510
|
+
"/v1/{full_path:path}",
|
|
511
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
512
|
+
)
|
|
513
|
+
async def proxy_handler(full_path: str, request: Request):
|
|
514
|
+
if not state.running:
|
|
515
|
+
return JSONResponse({"error": "proxy stopped"}, status_code=503)
|
|
516
|
+
|
|
517
|
+
state.stats.requests += 1
|
|
518
|
+
|
|
519
|
+
body = await request.body()
|
|
520
|
+
payload = None
|
|
521
|
+
if body:
|
|
522
|
+
try:
|
|
523
|
+
payload = json.loads(body)
|
|
524
|
+
except json.JSONDecodeError:
|
|
525
|
+
payload = None
|
|
526
|
+
|
|
527
|
+
# Model alias: "openvidia" resolves to the user's active model.
|
|
528
|
+
if isinstance(payload, dict):
|
|
529
|
+
req_model = payload.get("model")
|
|
530
|
+
if req_model == "openvidia" or not req_model:
|
|
531
|
+
resolved = default_model(state)
|
|
532
|
+
if not resolved:
|
|
533
|
+
return JSONResponse(
|
|
534
|
+
{"error": "no model selected — pick one in the dashboard"},
|
|
535
|
+
status_code=400,
|
|
536
|
+
)
|
|
537
|
+
payload["model"] = resolved
|
|
538
|
+
body = json.dumps(payload).encode()
|
|
539
|
+
|
|
540
|
+
# Thinking toggle (dashboard setting; never overrides the client).
|
|
541
|
+
if isinstance(payload, dict) and payload.get("model"):
|
|
542
|
+
before = json.dumps(payload, sort_keys=True)
|
|
543
|
+
config.apply_model_options(payload)
|
|
544
|
+
if json.dumps(payload, sort_keys=True) != before:
|
|
545
|
+
body = json.dumps(payload).encode()
|
|
546
|
+
|
|
547
|
+
# Auto-compaction: if conversation history exceeds the token budget,
|
|
548
|
+
# summarize older turns transparently so the request stays under limits.
|
|
549
|
+
if (
|
|
550
|
+
isinstance(payload, dict)
|
|
551
|
+
and isinstance(payload.get("messages"), list)
|
|
552
|
+
and full_path.endswith("chat/completions")
|
|
553
|
+
):
|
|
554
|
+
from .compaction import maybe_compact
|
|
555
|
+
|
|
556
|
+
new_messages = await maybe_compact(
|
|
557
|
+
payload["messages"],
|
|
558
|
+
state=state,
|
|
559
|
+
client=client,
|
|
560
|
+
log=state.log_cb,
|
|
561
|
+
model=payload.get("model", "") or "",
|
|
562
|
+
)
|
|
563
|
+
if new_messages is not payload["messages"]:
|
|
564
|
+
payload["messages"] = new_messages
|
|
565
|
+
body = json.dumps(payload).encode()
|
|
566
|
+
|
|
567
|
+
# ── Embedding cache (deterministic, RPM-heavy) ────────────────
|
|
568
|
+
# Vectors are deterministic per (model, input): the cache avoids
|
|
569
|
+
# burning the free-tier RPM budget on repeated identical requests
|
|
570
|
+
# (clients recompute them on every restart).
|
|
571
|
+
is_embeddings = (
|
|
572
|
+
isinstance(payload, dict)
|
|
573
|
+
and isinstance(payload.get("input"), (str, list))
|
|
574
|
+
and full_path.endswith("embeddings")
|
|
575
|
+
)
|
|
576
|
+
if is_embeddings:
|
|
577
|
+
cached = emb_cache.get(str(payload.get("model", "")), payload["input"])
|
|
578
|
+
if cached is not None:
|
|
579
|
+
return Response(content=cached, media_type="application/json")
|
|
580
|
+
|
|
581
|
+
# ── Key rotation ──────────────────────────────────────────────
|
|
582
|
+
async with state.lock:
|
|
583
|
+
candidates = state.get_candidate_keys()
|
|
584
|
+
|
|
585
|
+
if not candidates:
|
|
586
|
+
state.log_cb("✗ No valid keys available")
|
|
587
|
+
return JSONResponse({"error": "no valid keys available"}, status_code=503)
|
|
588
|
+
|
|
589
|
+
# ── Circuit breaker: skip model if too many consecutive failures ──────
|
|
590
|
+
# When glm-5.2 or laguna-xs are down on NVIDIA, ALL keys will timeout.
|
|
591
|
+
# Instead of spending 30s×5 attempts = 2.5min on a known-dead model,
|
|
592
|
+
# check the circuit and auto-failover to the next healthy preset.
|
|
593
|
+
requested_model = payload.get("model", "") if isinstance(payload, dict) else ""
|
|
594
|
+
if requested_model and state.is_model_circuit_open(requested_model):
|
|
595
|
+
# Respect user's fallback preference: "off" = never failover, return 503
|
|
596
|
+
_opts = config.model_options()
|
|
597
|
+
_fallback_mode = _opts.get("fallback", "off")
|
|
598
|
+
_per = (_opts.get("per_model") or {}).get(requested_model, {})
|
|
599
|
+
_fallback_mode = _per.get("fallback", _fallback_mode)
|
|
600
|
+
if _fallback_mode == "off":
|
|
601
|
+
state.log_cb(f"🔴 {requested_model} circuit OPEN, fallback=off → 503")
|
|
602
|
+
return JSONResponse(
|
|
603
|
+
{"error": f"{requested_model} is down (circuit open), fallback disabled"},
|
|
604
|
+
status_code=503,
|
|
605
|
+
)
|
|
606
|
+
# Try to failover to the next working preset
|
|
607
|
+
presets = config.load_saved_presets()
|
|
608
|
+
fallback = next(
|
|
609
|
+
(m for m in presets if m != requested_model and not state.is_model_circuit_open(m)),
|
|
610
|
+
None,
|
|
611
|
+
)
|
|
612
|
+
if fallback and isinstance(payload, dict):
|
|
613
|
+
state.log_cb(f"🔴 {requested_model} circuit OPEN → auto-failover to {fallback}")
|
|
614
|
+
payload["model"] = fallback
|
|
615
|
+
body = json.dumps(payload).encode()
|
|
616
|
+
requested_model = fallback
|
|
617
|
+
else:
|
|
618
|
+
state.log_cb(f"🔴 {requested_model} circuit OPEN, no healthy fallback")
|
|
619
|
+
return JSONResponse(
|
|
620
|
+
{"error": f"{requested_model} is down (circuit open), no fallback available"},
|
|
621
|
+
status_code=503,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
nv_path = full_path[3:] if full_path.startswith("v1/") else full_path
|
|
625
|
+
endpoint_urls = router.healthy_endpoints() or [UPSTREAM_BASE]
|
|
626
|
+
|
|
627
|
+
CLIENT_FWD_HEADERS = {"content-type", "accept", "x-request-id", "x-trace-id"}
|
|
628
|
+
|
|
629
|
+
# Saturation gate: weigh live (cooldown-free, RPM-eligible) candidates
|
|
630
|
+
# against the FULL pool size, not just len(candidates). The proxy's
|
|
631
|
+
# get_candidate_keys() drops invalid keys and sorts cooldown ones to the
|
|
632
|
+
# tail, so len(candidates) can be small even when the pool is healthy.
|
|
633
|
+
# Using the full pool as the denominator makes the gate fire correctly
|
|
634
|
+
# when most of the 25 keys are on cooldown (the historical Codex block).
|
|
635
|
+
_live, _total_pool = _live_pool_snapshot(state, candidates)
|
|
636
|
+
_pool_saturated = _total_pool > 0 and _live < max(1, int(_total_pool * _MIN_LIVE_FRACTION))
|
|
637
|
+
last_status = 429 if _pool_saturated else 503
|
|
638
|
+
if _pool_saturated:
|
|
639
|
+
state.log_cb(
|
|
640
|
+
f"⚠ pool saturated ({_live}/{_total_pool} live) → skip rotation, try model fallback"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# Bounded rotation via shared _rotation_phase (same logic as shims).
|
|
644
|
+
# The catch-all passes method + raw content so it supports GET, POST,
|
|
645
|
+
# PUT, etc. — the shims use the default POST + json=payload.
|
|
646
|
+
_payload_for_rotation = payload if isinstance(payload, dict) else {"model": ""}
|
|
647
|
+
_outcome: dict = {}
|
|
648
|
+
|
|
649
|
+
def _hdr(k, idx):
|
|
650
|
+
h = {
|
|
651
|
+
"Authorization": f"Bearer {k}",
|
|
652
|
+
"User-Agent": "openvidia/2.0",
|
|
653
|
+
}
|
|
654
|
+
for hk, hv in request.headers.items():
|
|
655
|
+
if hk.lower() in CLIENT_FWD_HEADERS:
|
|
656
|
+
h[hk] = hv
|
|
657
|
+
if "content-type" not in {hk.lower() for hk in h}:
|
|
658
|
+
h["Content-Type"] = "application/json"
|
|
659
|
+
return h
|
|
660
|
+
|
|
661
|
+
# Multi-endpoint failover: try the first healthy endpoint; on a 5xx or
|
|
662
|
+
# a timeout the host is probably partially down, so blacklist it for
|
|
663
|
+
# ENDPOINT_RETRY_AFTER and move on to the next one. A deterministic
|
|
664
|
+
# error (400/401/404) will not turn out differently on another host:
|
|
665
|
+
# stop right away. An "exhausted" (budget spent) is NOT the host being
|
|
666
|
+
# down: try the next one without blacklisting.
|
|
667
|
+
resp = None
|
|
668
|
+
used_key = ""
|
|
669
|
+
used_idx = -1
|
|
670
|
+
for endpoint in endpoint_urls:
|
|
671
|
+
url = endpoint + nv_path
|
|
672
|
+
resp, used_key, used_idx = await _rotation_phase(
|
|
673
|
+
client,
|
|
674
|
+
url,
|
|
675
|
+
_payload_for_rotation,
|
|
676
|
+
_hdr,
|
|
677
|
+
state,
|
|
678
|
+
candidates,
|
|
679
|
+
max_attempts=_MAX_ROTATE_ATTEMPTS,
|
|
680
|
+
timeout=_ROTATE_SEND_TIMEOUT,
|
|
681
|
+
stream=not is_embeddings,
|
|
682
|
+
log_tag="catch-all",
|
|
683
|
+
outcome_box=_outcome,
|
|
684
|
+
method=request.method,
|
|
685
|
+
content=body if body else None,
|
|
686
|
+
probe_timeout=_MODEL_PROBE_TIMEOUT,
|
|
687
|
+
)
|
|
688
|
+
if resp is not None and resp.status_code == 200:
|
|
689
|
+
router.mark_success(url)
|
|
690
|
+
break
|
|
691
|
+
_endpoint_dead = (resp is not None and resp.status_code >= 500) or (
|
|
692
|
+
resp is None and (_outcome.get("status") or 0) >= 500
|
|
693
|
+
)
|
|
694
|
+
if _endpoint_dead and not _outcome.get("exhausted"):
|
|
695
|
+
router.mark_failure(url)
|
|
696
|
+
if _outcome.get("deterministic"):
|
|
697
|
+
break
|
|
698
|
+
resp = None
|
|
699
|
+
|
|
700
|
+
if resp is not None and resp.status_code == 200:
|
|
701
|
+
# CORS is the middleware's job. Setting the headers by hand here
|
|
702
|
+
# re-introduced the wildcard the middleware no longer sends, on the
|
|
703
|
+
# one route that carries model output.
|
|
704
|
+
out_headers = {
|
|
705
|
+
k: v for k, v in resp.headers.items() if k.lower() not in STRIPPED_RESPONSE_HEADERS
|
|
706
|
+
}
|
|
707
|
+
state.stats.success += 1
|
|
708
|
+
state.stats.record_key_usage(used_key, ok=True)
|
|
709
|
+
_sent_model = _payload_for_rotation.get("model", "")
|
|
710
|
+
if used_key and _sent_model:
|
|
711
|
+
state.record_key_model_result(used_key, _sent_model, ok=True)
|
|
712
|
+
|
|
713
|
+
if is_embeddings:
|
|
714
|
+
emb_body = await resp.aread()
|
|
715
|
+
await resp.aclose()
|
|
716
|
+
state.end_in_flight(used_key)
|
|
717
|
+
if _sent_model:
|
|
718
|
+
emb_cache.set(_sent_model, payload["input"], emb_body)
|
|
719
|
+
return Response(content=emb_body, media_type="application/json")
|
|
720
|
+
|
|
721
|
+
async def body_iter(resp=resp, key=used_key, orig_idx=used_idx):
|
|
722
|
+
try:
|
|
723
|
+
# aiter_bytes, not aiter_raw: httpx asks for gzip by
|
|
724
|
+
# default, and content-encoding is stripped from the
|
|
725
|
+
# forwarded headers above. Passing the raw stream through
|
|
726
|
+
# would hand the client compressed bytes labelled identity.
|
|
727
|
+
async for chunk in resp.aiter_bytes():
|
|
728
|
+
if await request.is_disconnected():
|
|
729
|
+
break
|
|
730
|
+
yield chunk
|
|
731
|
+
except httpx.HTTPError as e:
|
|
732
|
+
# The client already has a 200 and some bytes, so there is
|
|
733
|
+
# no status left to change. Say so in the stream instead of
|
|
734
|
+
# closing cleanly, which reads as a complete answer.
|
|
735
|
+
state.log_cb(f"key[{orig_idx}] stream error: {e}")
|
|
736
|
+
yield b'\ndata: {"error":"upstream stream interrupted"}\n\n'
|
|
737
|
+
finally:
|
|
738
|
+
state.end_in_flight(key)
|
|
739
|
+
await resp.aclose()
|
|
740
|
+
|
|
741
|
+
return StreamingResponse(body_iter(), status_code=resp.status_code, headers=out_headers)
|
|
742
|
+
|
|
743
|
+
# Rotation failed. _rotation_phase already closed the response and
|
|
744
|
+
# captured the upstream status and body in _outcome.
|
|
745
|
+
if _outcome.get("status"):
|
|
746
|
+
last_status = _outcome["status"]
|
|
747
|
+
|
|
748
|
+
model_name = payload.get("model", "") if isinstance(payload, dict) else ""
|
|
749
|
+
# Same exemption the rotation loop applies: a saturated upstream worker
|
|
750
|
+
# pool says nothing about this model, and scoring it here would reopen
|
|
751
|
+
# the circuit the loop just declined to open.
|
|
752
|
+
if model_name and not _outcome.get("exhausted"):
|
|
753
|
+
state.record_model_result(model_name, status=last_status)
|
|
754
|
+
if used_key:
|
|
755
|
+
state.record_key_model_result(used_key, model_name, ok=False)
|
|
756
|
+
|
|
757
|
+
# A request the provider rejected outright keeps its own status: a 400
|
|
758
|
+
# reported as 503 tells the client to retry something that cannot work.
|
|
759
|
+
if _outcome.get("deterministic"):
|
|
760
|
+
return JSONResponse(
|
|
761
|
+
{
|
|
762
|
+
"error": _upstream_error_message(_outcome),
|
|
763
|
+
"last_upstream_status": last_status,
|
|
764
|
+
},
|
|
765
|
+
status_code=last_status or 400,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
if state.is_pool_throttled():
|
|
769
|
+
retry_in = int(state.pool_throttle_remaining()) + 1
|
|
770
|
+
return JSONResponse(
|
|
771
|
+
{
|
|
772
|
+
"error": (
|
|
773
|
+
"upstream rate limit applies to the whole account, not to individual "
|
|
774
|
+
"keys — rotating cannot help. Retry shortly."
|
|
775
|
+
),
|
|
776
|
+
"last_upstream_status": 429,
|
|
777
|
+
},
|
|
778
|
+
status_code=429,
|
|
779
|
+
headers={"Retry-After": str(retry_in)},
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
# ── Provider free-tier fallback (opt-in via providers.json) ──
|
|
783
|
+
# Does not touch the NVIDIA keys (no record_key_usage: the success
|
|
784
|
+
# is not the pool's doing), but it counts as a served request.
|
|
785
|
+
if isinstance(payload, dict) and full_path.endswith("chat/completions"):
|
|
786
|
+
for provider in load_provider_configs():
|
|
787
|
+
fallback_resp = await try_fallback(client, payload, provider)
|
|
788
|
+
if fallback_resp is None:
|
|
789
|
+
continue
|
|
790
|
+
state.log_cb(f"⇄ fallback → {provider.name}")
|
|
791
|
+
state.stats.success += 1
|
|
792
|
+
f_body = await fallback_resp.aread()
|
|
793
|
+
await fallback_resp.aclose()
|
|
794
|
+
return Response(content=f_body, media_type="application/json")
|
|
795
|
+
|
|
796
|
+
msg = "all keys exhausted"
|
|
797
|
+
if model_name:
|
|
798
|
+
msg += f" for {model_name}"
|
|
799
|
+
if model_name and state.is_model_circuit_open(model_name):
|
|
800
|
+
msg += " (circuit open — will auto-failover on next request)"
|
|
801
|
+
return JSONResponse(
|
|
802
|
+
{
|
|
803
|
+
"error": _upstream_error_message(_outcome, msg),
|
|
804
|
+
"last_upstream_status": last_status,
|
|
805
|
+
},
|
|
806
|
+
status_code=last_status,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# ── /ops/health — live model & pool health dashboard ──────────────
|
|
810
|
+
@app.get("/ops/health")
|
|
811
|
+
async def _ops_health() -> JSONResponse:
|
|
812
|
+
"""Structured health report: model circuit states, pool stats, recent logs."""
|
|
813
|
+
now = time.time()
|
|
814
|
+
models_out = []
|
|
815
|
+
for model, h in state.model_health.items():
|
|
816
|
+
models_out.append(
|
|
817
|
+
{
|
|
818
|
+
"model": model,
|
|
819
|
+
"requests": h.requests,
|
|
820
|
+
"success": h.success,
|
|
821
|
+
"failure_rate": round(h.failure_rate, 2),
|
|
822
|
+
"too_slow": h.too_slow,
|
|
823
|
+
"gateway_timeouts": h.gateway_timeouts,
|
|
824
|
+
"rate_limited": h.rate_limited,
|
|
825
|
+
"median_ttft_s": round(h.median_ttft, 1),
|
|
826
|
+
"circuit_open": h.is_circuit_open,
|
|
827
|
+
"consecutive_failures": h.consecutive_failures,
|
|
828
|
+
"circuit_reset_in_s": max(
|
|
829
|
+
0, round(h.CIRCUIT_RESET_AFTER - (now - h.circuit_opened_at), 1)
|
|
830
|
+
)
|
|
831
|
+
if h.is_circuit_open
|
|
832
|
+
else 0,
|
|
833
|
+
}
|
|
834
|
+
)
|
|
835
|
+
live_keys, valid_keys = state.count_live_candidates()
|
|
836
|
+
recent_logs = list(state.log_buffer)[-50:]
|
|
837
|
+
return JSONResponse(
|
|
838
|
+
{
|
|
839
|
+
"pool": {
|
|
840
|
+
"n_keys": len(state.keys),
|
|
841
|
+
"n_healthy": valid_keys,
|
|
842
|
+
"n_live_rpm": live_keys,
|
|
843
|
+
"n_on_cooldown": sum(1 for k in state.keys if state.is_key_on_cooldown(k)),
|
|
844
|
+
"aggregate_rpm": sum(state.key_rpm(k) for k in state.keys),
|
|
845
|
+
"rpm_ceiling": len(state.keys) * 28,
|
|
846
|
+
"aggregate_observed_ceiling": sum(
|
|
847
|
+
t.observed_ceiling for t in state.rpm.values() if t.observed_ceiling
|
|
848
|
+
),
|
|
849
|
+
},
|
|
850
|
+
"endpoints": router.as_dict(),
|
|
851
|
+
"embedding_cache": emb_cache.as_dict(),
|
|
852
|
+
"redis": bool(rs.enabled) if rs is not None else False,
|
|
853
|
+
"models": models_out,
|
|
854
|
+
"presets": config.load_saved_presets(),
|
|
855
|
+
"active_model": state.active_model,
|
|
856
|
+
"total_requests": state.stats.requests,
|
|
857
|
+
"total_success": state.stats.success,
|
|
858
|
+
"total_rotations": state.stats.rotations,
|
|
859
|
+
"recent_logs": recent_logs,
|
|
860
|
+
}
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
return app
|