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