File size: 7,221 Bytes
177caf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""
NVIDIA NIM Proxy β€” Gradio/FastAPI Edition
Runs FOR FREE on Hugging Face Spaces (CPU Basic, no credit card needed).
"""

import asyncio
import json
import os
import re
import time
from typing import Optional, Union

import httpx
import gradio as gr
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse

# ── Config ──────────────────────────────────────────────────────────────────
NVIDIA_URL = os.environ.get("NVIDIA_BASE_URL", "https://integrate.api.nvidia.com/v1")
COOLDOWN = int(os.environ.get("KEY_COOLDOWN_SECONDS", str(15 * 60)))
KEEPALIVE = float(os.environ.get("KEEPALIVE_INTERVAL", "5"))
RETRIES = int(os.environ.get("TRANSIENT_RETRIES", "3"))
BACKOFF = float(os.environ.get("BACKOFF_BASE", "1.0"))

PROXY_AUTH_TOKEN = os.environ.get("PROXY_AUTH_TOKEN", "").strip()
THINK_MIN_TOKENS = int(os.environ.get("THINK_MIN_TOKENS", "202000"))

ALLOWED = {
    "model", "messages", "tools", "tool_choice",
    "temperature", "top_p", "top_k", "max_tokens",
    "stream", "seed", "stop", "response_format",
    "frequency_penalty", "presence_penalty",
    "logprobs", "top_logprobs", "n",
    "chat_template_kwargs",
}

KEYS: list[dict] = []
for i in range(1, 9):
    k = os.environ.get(f"NVIDIA_KEY_{i}")
    if k and k.strip():
        KEYS.append({"i": i, "key": k.strip()})

cooldown: dict[int, float] = {}
_rr = 0

# ── FastAPI App ────────────────────────────────────────────────────────────
app = FastAPI()

def _err(code: int, msg: str) -> JSONResponse:
    return JSONResponse(status_code=code, content={"error": {"message": msg, "type": "error"}})

def _check_auth(auth: Optional[str]) -> Optional[JSONResponse]:
    if not PROXY_AUTH_TOKEN: return _err(401, "PROXY_AUTH_TOKEN not configured.")
    if not auth: return _err(401, "Missing Authorization header.")
    parts = auth.split(" ", 1)
    if len(parts) != 2 or parts[0].lower() != "bearer": return _err(401, "Malformed Authorization header.")
    if parts[1].strip() != PROXY_AUTH_TOKEN: return _err(401, "Invalid token.")
    return None

def _build_payload(body: dict) -> dict:
    extra = body.pop("extra_body", None)
    if isinstance(extra, dict): body.update(extra)

    if "max_completion_tokens" in body and "max_tokens" not in body:
        body["max_tokens"] = body["max_completion_tokens"]

    body = {k: v for k, v in body.items() if k in ALLOWED}
    body.pop("reasoning_effort", None)

    model = str(body.get("model", "")).lower()
    if "glm" in model or "gemma" in model:
        ctk = body.get("chat_template_kwargs")
        if isinstance(ctk, dict):
            ctk.setdefault("enable_thinking", True)
            ctk.setdefault("reasoning_effort", "max")
            ctk.pop("max_thinking_tokens", None)
            body["chat_template_kwargs"] = ctk
        else:
            body["chat_template_kwargs"] = {"enable_thinking": True, "reasoning_effort": "max"}

        mt = body.get("max_tokens")
        try: mt = int(mt)
        except: mt = 0
        if mt < THINK_MIN_TOKENS: body["max_tokens"] = THINK_MIN_TOKENS

    return body

async def _post(url, payload, accept="text/event-stream"):
    if not KEYS: return _err(503, "No NVIDIA keys configured.")
    global _rr
    last_status, last_detail = None, "No response."

    for attempt in range(RETRIES + 1):
        order = [KEYS[(_rr + i) % len(KEYS)] for i in range(len(KEYS))]
        _rr = (_rr + 1) % len(KEYS)
        now = time.time()
        avail = [e for e in order if now >= cooldown.get(e["i"], 0)]
        if not avail: return _err(429, f"All keys cooling down ({COOLDOWN // 60} min). Retry later.")

        transient = False
        for e in avail:
            headers = {"Authorization": f"Bearer {e['key']}", "Content-Type": "application/json", "Accept": accept, "Accept-Encoding": "identity"}
            try:
                async with httpx.AsyncClient() as client:
                    resp = await client.post(url, json=payload, headers=headers)
            except Exception as ex:
                last_status, last_detail, transient = 502, str(ex), True
                continue

            status = resp.status_code
            if status == 429 or status >= 500:
                if status == 429: cooldown[e["i"]] = time.time() + COOLDOWN
                last_detail, last_status, transient = resp.text[:2000], status, True
                continue
            elif status >= 400:
                detail = resp.text[:2000]
                if "DEGRADED" in detail.upper():
                    last_status, last_detail, transient = status, detail, True
                    continue
                return _err(status, detail)
            else:
                return resp

        if transient and attempt < RETRIES:
            await asyncio.sleep(BACKOFF * (attempt + 1))

    return _err(last_status or 502, f"All keys failed after {RETRIES + 1} tries. Last: HTTP {last_status} - {last_detail}")

async def _forward(resp: httpx.Response):
    async for chunk in resp.aiter_raw():
        yield chunk

@app.post("/v1/chat/completions")
async def chat(request: Request):
    auth_err = _check_auth(request.headers.get("authorization"))
    if auth_err: return auth_err
    try: body = await request.json()
    except: return _err(400, "Invalid JSON.")
    if not isinstance(body, dict): return _err(400, "Body must be a JSON object.")

    payload = _build_payload(body)
    is_stream = bool(payload.get("stream", False))

    if is_stream:
        async def stream_with_keepalive():
            yield b": proxy-connected\n\n"
            result = await _post(f"{NVIDIA_URL}/chat/completions", payload)
            if isinstance(result, JSONResponse):
                yield b"data: " + result.body + b"\n\n"
                return
            async for chunk in _forward(result):
                yield chunk
        return StreamingResponse(stream_with_keepalive(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

    result = await _post(f"{NVIDIA_URL}/chat/completions", payload)
    if isinstance(result, JSONResponse): return result
    return Response(result.content, media_type=result.headers.get("content-type", "application/json"))

@app.get("/health")
async def health(): return {"status": "ok", "keys": len(KEYS)}

@app.get("/models")
async def models():
    return {"data": [{"id": "z-ai/glm-5.2", "object": "model"}, {"id": "google/diffusiongemma-26b-a4b-it", "object": "model"}]}

# ── Gradio UI Wrapper ──────────────────────────────────────────────────────
with gr.Blocks(title="NIM Proxy") as demo:
    gr.Markdown("# βœ… NVIDIA NIM Proxy is Running")
    gr.Markdown("API is accessible at `/v1/chat/completions`")

app = gr.mount_gradio_app(app, demo, path="/")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=7860)