| """ |
| Unified AI Proxy β main.py |
| Self-hosted, OpenAI-compatible API gateway + encrypted key manager + dashboard. |
| """ |
|
|
| import os |
| import re |
| import json |
| import time |
| import uuid |
| import secrets |
| from contextlib import asynccontextmanager |
| from datetime import datetime |
| from typing import AsyncGenerator |
|
|
| import httpx |
| from cryptography.fernet import Fernet |
| from fastapi import FastAPI, Depends, HTTPException, Request |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response |
| from pydantic import BaseModel |
| from sqlalchemy import ( |
| Column, String, Boolean, Integer, DateTime, Float, Text, |
| create_engine, select, update, delete |
| ) |
| from sqlalchemy.orm import DeclarativeBase, sessionmaker |
| from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker |
|
|
| |
|
|
| if os.path.isdir("/app/data"): |
| ASYNC_DB = "sqlite+aiosqlite:////app/data/db.sqlite" |
| SYNC_DB = "sqlite:////app/data/db.sqlite" |
| elif os.path.isdir("/data"): |
| ASYNC_DB = "sqlite+aiosqlite:////data/db.sqlite" |
| SYNC_DB = "sqlite:////data/db.sqlite" |
| else: |
| ASYNC_DB = "sqlite+aiosqlite:///./db.sqlite" |
| SYNC_DB = "sqlite:///./db.sqlite" |
|
|
|
|
| class Base(DeclarativeBase): |
| pass |
|
|
|
|
| class Provider(Base): |
| __tablename__ = "providers" |
| id = Column(Integer, primary_key=True, autoincrement=True) |
| name = Column(String(100), unique=True, nullable=False) |
| base_url = Column(String(500), nullable=False) |
| api_key = Column(String(500), nullable=False) |
| enabled = Column(Boolean, default=True) |
| is_default = Column(Boolean, default=False) |
| created_at = Column(DateTime, default=datetime.utcnow) |
|
|
|
|
| class RequestLog(Base): |
| __tablename__ = "request_logs" |
| id = Column(Integer, primary_key=True, autoincrement=True) |
| timestamp = Column(DateTime, default=datetime.utcnow) |
| provider_name = Column(String(100)) |
| model = Column(String(200)) |
| status_code = Column(Integer) |
| latency_ms = Column(Float) |
| path = Column(String(200)) |
| streaming = Column(Boolean, default=False) |
|
|
|
|
| class Settings(Base): |
| __tablename__ = "settings" |
| key = Column(String(100), primary_key=True) |
| value = Column(Text, nullable=False) |
|
|
|
|
| def init_db(): |
| engine = create_engine(SYNC_DB, connect_args={"check_same_thread": False}) |
| Base.metadata.create_all(engine) |
| Session = sessionmaker(bind=engine) |
| with Session() as s: |
| |
| enc_row = s.get(Settings, "encryption_key") |
| if not enc_row: |
| enc_key = Fernet.generate_key().decode() |
| s.add(Settings(key="encryption_key", value=enc_key)) |
| else: |
| enc_key = enc_row.value |
| cipher = Fernet(enc_key.encode()) |
|
|
| |
| env_master = os.environ.get("MASTER_KEY") or os.environ.get("MASTER_API_KEY") or os.environ.get("PROXY_MASTER_KEY") |
| existing = s.get(Settings, "master_key") |
| if env_master: |
| if existing: |
| existing.value = env_master |
| else: |
| s.add(Settings(key="master_key", value=env_master)) |
| print("[startup] Master key loaded securely from Space Secret.") |
| elif not existing: |
| key = f"umk-{secrets.token_urlsafe(32)}" |
| s.add(Settings(key="master_key", value=key)) |
| print(f"\n{'='*60}\n [SECURITY WARNING] Auto-generated MASTER API KEY:\n {key}\n Since this Space is public, set 'MASTER_KEY' in Space Secrets to hide this log!\n{'='*60}\n") |
| else: |
| print(f"[startup] Master key loaded from DB (ends: ...{existing.value[-4:]})") |
|
|
| |
| for env_k, env_v in os.environ.items(): |
| m = re.match(r"^PROVIDER_([A-Z0-9_]+)_URL$", env_k) |
| if m: |
| pname = m.group(1).capitalize() |
| key_env = f"PROVIDER_{m.group(1)}_KEY" |
| pkey = os.environ.get(key_env, "") |
| if pkey and not s.query(Provider).filter_by(name=pname).first(): |
| enc_pkey = cipher.encrypt(pkey.encode()).decode() |
| s.add(Provider(name=pname, base_url=env_v, api_key=enc_pkey)) |
| print(f"[startup] Seeded provider: {pname}") |
| s.commit() |
| return cipher |
|
|
|
|
| def _decrypt(cipher: Fernet, enc: str) -> str: |
| if not enc: return "" |
| try: |
| return cipher.decrypt(enc.encode()).decode() |
| except Exception: |
| return enc |
|
|
|
|
| |
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| cipher = init_db() |
| app.state.cipher = cipher |
| engine = create_async_engine(ASYNC_DB) |
| app.state.db = async_sessionmaker(engine, expire_on_commit=False) |
| yield |
| await engine.dispose() |
|
|
|
|
| app = FastAPI(title="Unified AI Proxy", lifespan=lifespan) |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) |
|
|
| TIMEOUT = httpx.Timeout(120.0, connect=10.0) |
|
|
| |
|
|
| async def verify_key(request: Request): |
| auth = request.headers.get("Authorization", "") |
| api_key_header = request.headers.get("api-key", "") |
| token = "" |
| if auth.startswith("Bearer "): |
| token = auth.removeprefix("Bearer ").strip() |
| elif api_key_header: |
| token = api_key_header.strip() |
|
|
| if not token: |
| raise HTTPException(401, "Missing API key in Authorization (Bearer) or api-key header.") |
|
|
| async with request.app.state.db() as s: |
| row = await s.get(Settings, "master_key") |
| if not row or not secrets.compare_digest(row.value, token): |
| raise HTTPException(401, "Invalid master API key.") |
|
|
|
|
| @app.exception_handler(HTTPException) |
| async def openai_exception_handler(request: Request, exc: HTTPException): |
| if request.url.path.startswith("/v1/"): |
| return JSONResponse( |
| status_code=exc.status_code, |
| content={ |
| "error": { |
| "message": str(exc.detail), |
| "type": "invalid_request_error" if exc.status_code < 500 else "api_error", |
| "param": None, |
| "code": exc.status_code |
| } |
| }, |
| headers={"x-request-id": f"req_{uuid.uuid4().hex}"} |
| ) |
| return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) |
|
|
|
|
| @app.get("/health") |
| async def health(request: Request): |
| async with request.app.state.db() as s: |
| result = await s.execute(select(Provider).where(Provider.enabled == True)) |
| n = len(result.scalars().all()) |
| return {"status": "ok", "providers": n} |
|
|
| |
|
|
| DASHBOARD_HTML = r"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"/> |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> |
| <title>Unified AI Proxy</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"/> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/> |
| <style> |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} |
| :root{--bg:#0d1117;--surface:#161b22;--surface2:#1c2128;--border:#30363d;--accent:#58a6ff;--accent-dim:#1f3a5f;--green:#3fb950;--red:#f85149;--yellow:#d29922;--text:#e6edf3;--text-dim:#8b949e;--mono:'JetBrains Mono',monospace;--sans:'Inter',system-ui,sans-serif} |
| body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;min-height:100vh} |
| #login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px} |
| .login-box{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:36px 32px;width:100%;max-width:400px} |
| .login-box h1{font-size:20px;font-weight:600;margin-bottom:6px} |
| .login-box p{color:var(--text-dim);margin-bottom:24px;font-size:13px} |
| .err{color:var(--red);font-size:13px;margin-top:10px;display:none} |
| #app{display:none;flex-direction:column;min-height:100vh} |
| header{background:var(--surface);border-bottom:1px solid var(--border);padding:0 24px;display:flex;align-items:center;gap:16px;height:52px;position:sticky;top:0;z-index:10} |
| .logo{font-family:var(--mono);font-size:15px;font-weight:500;color:var(--accent)} |
| .logo span{color:var(--text-dim)} |
| nav{display:flex;gap:2px;margin-left:8px} |
| nav button{background:none;border:none;color:var(--text-dim);cursor:pointer;padding:6px 14px;border-radius:6px;font-size:13px;font-family:var(--sans);transition:all .15s} |
| nav button:hover{background:var(--surface2);color:var(--text)} |
| nav button.active{background:var(--accent-dim);color:var(--accent)} |
| .spacer{flex:1} |
| .hstatus{font-size:12px;color:var(--text-dim);font-family:var(--mono)} |
| main{flex:1;padding:24px;max-width:1100px;margin:0 auto;width:100%} |
| .tab{display:none}.tab.active{display:block} |
| .sh{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px} |
| .sh h2{font-size:16px;font-weight:600} |
| .card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:20px;margin-bottom:16px} |
| .card-title{font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;margin-bottom:16px} |
| .form-row{display:grid;gap:12px;margin-bottom:12px} |
| .cols-3{grid-template-columns:1fr 1fr 1fr} |
| .cols-2{grid-template-columns:1fr 1fr} |
| label{display:block;font-size:12px;color:var(--text-dim);margin-bottom:5px;font-weight:500} |
| input[type=text],input[type=password]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px 12px;font-family:var(--mono);font-size:13px;transition:border-color .15s;outline:none} |
| input:focus{border-color:var(--accent)} |
| .iw{position:relative}.iw input{padding-right:40px} |
| .tv{position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--text-dim);font-size:12px;padding:2px 4px} |
| .tv:hover{color:var(--text)} |
| .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:6px;font-size:13px;font-family:var(--sans);font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s;line-height:1} |
| .bp{background:var(--accent);color:#000;border-color:var(--accent)}.bp:hover{background:#79b8ff} |
| .bs{background:var(--surface2);color:var(--text);border-color:var(--border)}.bs:hover{border-color:var(--accent);color:var(--accent)} |
| .bd{background:transparent;color:var(--red);border-color:var(--border)}.bd:hover{background:rgba(248,81,73,.1);border-color:var(--red)} |
| .sm{padding:4px 10px;font-size:12px} |
| .tw{overflow-x:auto} |
| table{width:100%;border-collapse:collapse} |
| th{text-align:left;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;padding:0 12px 10px;border-bottom:1px solid var(--border)} |
| td{padding:11px 12px;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle} |
| tr:last-child td{border-bottom:none} |
| tr:hover td{background:var(--surface2)} |
| .mono{font-family:var(--mono);font-size:12px} |
| .pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500} |
| .pg{background:rgba(63,185,80,.15);color:var(--green)} |
| .pr{background:rgba(248,81,73,.15);color:var(--red)} |
| .pb{background:var(--accent-dim);color:var(--accent)} |
| .py{background:rgba(210,153,34,.15);color:var(--yellow)} |
| .toggle{position:relative;display:inline-block;width:36px;height:20px} |
| .toggle input{opacity:0;width:0;height:0} |
| .slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:.2s} |
| .slider::before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:var(--text-dim);border-radius:50%;transition:.2s} |
| input:checked+.slider{background:var(--accent)} |
| input:checked+.slider::before{transform:translateX(16px);background:#000} |
| .ub{background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-bottom:16px;overflow:hidden} |
| .ubh{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;cursor:pointer;user-select:none} |
| .ubh span{font-size:12px;color:var(--text-dim);font-weight:500} |
| .chev{color:var(--text-dim);transition:transform .2s} |
| .ubh.open .chev{transform:rotate(180deg)} |
| .ubc{display:none;padding:0 16px 16px} |
| .ubc.open{display:block} |
| pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:12px 14px;font-family:var(--mono);font-size:12px;color:var(--text);overflow-x:auto;line-height:1.6;margin-top:8px} |
| .empty{text-align:center;padding:40px;color:var(--text-dim);font-size:13px} |
| #flash{position:fixed;bottom:24px;right:24px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 18px;font-size:13px;box-shadow:0 4px 20px rgba(0,0,0,.5);opacity:0;transform:translateY(8px);transition:all .2s;pointer-events:none;z-index:99;max-width:320px} |
| #flash.show{opacity:1;transform:translateY(0)} |
| #flash.ok{border-left:3px solid var(--green)} |
| #flash.err{border-left:3px solid var(--red)} |
| @media(max-width:680px){.cols-3,.cols-2{grid-template-columns:1fr}.hstatus{display:none}} |
| </style> |
| </head> |
| <body> |
| <div id="login-screen"> |
| <div class="login-box"> |
| <h1>π Unified AI Proxy</h1> |
| <p>Enter your master API key to continue.</p> |
| <div><label>Master API Key</label> |
| <div class="iw"><input type="password" id="lk" placeholder="umk-..."/><button class="tv" onclick="tv('lk',this)">show</button></div> |
| </div> |
| <button class="btn bp" style="margin-top:14px;width:100%" onclick="doLogin()">Sign In</button> |
| <div class="err" id="lerr">Invalid key β check Space logs on first launch.</div> |
| </div> |
| </div> |
| |
| <div id="app"> |
| <header> |
| <div class="logo">π <span>unified-ai-proxy</span></div> |
| <nav> |
| <button class="active" onclick="showTab('providers',this)">Providers</button> |
| <button onclick="showTab('logs',this)">Logs</button> |
| <button onclick="showTab('settings',this)">Settings</button> |
| </nav> |
| <div class="spacer"></div> |
| <div class="hstatus" id="hs">β connectingβ¦</div> |
| </header> |
| <main> |
| |
| <!-- PROVIDERS --> |
| <div class="tab active" id="tab-providers"> |
| <div class="ub"> |
| <div class="ubh" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')"> |
| <span>π How to use the proxy</span><span class="chev">βΎ</span> |
| </div> |
| <div class="ubc"> |
| <p style="color:var(--text-dim);font-size:12px">Route to a specific provider using <code style="font-family:var(--mono);color:var(--accent)">providerName/modelName</code>:</p> |
| <pre>curl https://YOUR-SPACE.hf.space/v1/chat/completions \ |
| -H "Authorization: Bearer YOUR_MASTER_KEY" \ |
| -H "Content-Type: application/json" \ |
| -d '{"model":"groq/llama3-8b-8192","messages":[{"role":"user","content":"Hello!"}]}'</pre> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="card-title">Add Provider</div> |
| <div class="form-row cols-3"> |
| <div><label>Name</label><input type="text" id="an" placeholder="Groq"/></div> |
| <div><label>Base URL</label><input type="text" id="au" placeholder="https://api.groq.com/openai/v1"/></div> |
| <div><label>API Key</label><div class="iw"><input type="password" id="ak" placeholder="sk-..."/><button class="tv" onclick="tv('ak',this)">show</button></div></div> |
| </div> |
| <button class="btn bp" onclick="addProvider()">+ Add Provider</button> |
| </div> |
| <div class="card" style="padding:0"> |
| <div class="tw"><table> |
| <thead><tr><th>Name</th><th>Base URL</th><th>Key</th><th>Default</th><th>Enabled</th><th>Actions</th></tr></thead> |
| <tbody id="ptb"><tr><td colspan="6" class="empty">Loadingβ¦</td></tr></tbody> |
| </table></div> |
| </div> |
| </div> |
| |
| <!-- LOGS --> |
| <div class="tab" id="tab-logs"> |
| <div class="sh"><h2>Request Logs</h2><span style="font-size:12px;color:var(--text-dim)">Auto-refreshes every 5s Β· last 200</span></div> |
| <div class="card" style="padding:0"> |
| <div class="tw"><table> |
| <thead><tr><th>Time</th><th>Provider</th><th>Model</th><th>Path</th><th>Status</th><th>Latency</th><th>Stream</th></tr></thead> |
| <tbody id="ltb"><tr><td colspan="7" class="empty">No requests yet.</td></tr></tbody> |
| </table></div> |
| </div> |
| </div> |
| |
| <!-- SETTINGS --> |
| <div class="tab" id="tab-settings"> |
| <div class="card"> |
| <div class="card-title">Security & Encryption</div> |
| <div style="display:grid;gap:12px;margin-top:4px"> |
| <div style="display:flex;align-items:center;gap:12px;background:var(--bg);padding:14px;border-radius:6px;border:1px solid var(--border)"> |
| <span style="font-size:20px">π</span> |
| <div> |
| <div style="font-weight:600;font-size:13px;color:var(--green)">Provider API Keys Encrypted at Rest</div> |
| <div style="font-size:12px;color:var(--text-dim)">All backend provider keys are stored encrypted in SQLite (AES-128 via Fernet).</div> |
| </div> |
| </div> |
| <div id="sec-env-box" style="display:flex;align-items:center;gap:12px;background:var(--bg);padding:14px;border-radius:6px;border:1px solid var(--border)"> |
| <span style="font-size:20px" id="sec-icon">β³</span> |
| <div> |
| <div style="font-weight:600;font-size:13px" id="sec-title">Checking Master Key Securityβ¦</div> |
| <div style="font-size:12px;color:var(--text-dim)" id="sec-desc"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="card-title">Master API Key</div> |
| <p style="color:var(--text-dim);font-size:13px;margin-bottom:14px">Authenticates all proxy requests and dashboard access.</p> |
| <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"> |
| <code class="mono" id="kd" style="background:var(--bg);border:1px solid var(--border);padding:8px 14px;border-radius:6px">loadingβ¦</code> |
| <button class="btn bs sm" onclick="copyKey()">Copy Key</button> |
| <button class="btn bd sm" onclick="regenKey()">Regenerate</button> |
| </div> |
| <p style="color:var(--text-dim);font-size:12px;margin-top:10px">β Regenerating invalidates the current key immediately.</p> |
| </div> |
| <div class="card"> |
| <div class="card-title">Default Provider</div> |
| <p style="color:var(--text-dim);font-size:13px;margin-bottom:10px">Requests without a <code class="mono" style="font-size:12px">providerName/</code> prefix route here. Set via the Default toggle in Providers.</p> |
| <div id="defp" style="font-family:var(--mono);color:var(--accent);font-size:13px">loadingβ¦</div> |
| </div> |
| <div class="card"> |
| <div class="card-title">Base Endpoint</div> |
| <code class="mono" id="burl" style="background:var(--bg);border:1px solid var(--border);padding:8px 14px;border-radius:6px;font-size:12px;display:block"></code> |
| </div> |
| </div> |
| |
| </main> |
| </div> |
| <div id="flash"></div> |
| <script> |
| let masterKey = sessionStorage.getItem('mk') || ''; |
| let fullKey = ''; |
| |
| async function apiFetch(path, {key, method='GET', body}={}) { |
| const k = key||masterKey; |
| const opts = {method, headers:{'Authorization':`Bearer ${k}`,'Content-Type':'application/json'}}; |
| if(body) opts.body = JSON.stringify(body); |
| return fetch(path, opts); |
| } |
| |
| async function doLogin() { |
| const k = document.getElementById('lk').value.trim(); |
| if(!k) return; |
| const r = await apiFetch('/api/providers',{key:k}).catch(()=>null); |
| if(r&&r.ok) { masterKey=k; sessionStorage.setItem('mk',k); showApp(); } |
| else document.getElementById('lerr').style.display='block'; |
| } |
| document.getElementById('lk').addEventListener('keydown',e=>{ if(e.key==='Enter') doLogin(); }); |
| |
| function showApp() { |
| document.getElementById('login-screen').style.display='none'; |
| document.getElementById('app').style.display='flex'; |
| document.getElementById('burl').textContent = location.origin+'/v1'; |
| loadAll(); setInterval(loadLogs,5000); setInterval(updateStatus,10000); updateStatus(); |
| } |
| |
| if(masterKey) apiFetch('/api/providers').then(r=>{ if(r.ok) showApp(); else { masterKey=''; sessionStorage.removeItem('mk'); } }).catch(()=>{}); |
| |
| function flash(msg,type='ok') { |
| const el=document.getElementById('flash'); |
| el.textContent=msg; el.className=`show ${type}`; |
| clearTimeout(el._t); el._t=setTimeout(()=>el.className='',3000); |
| } |
| |
| function showTab(name,btn) { |
| document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active')); |
| document.querySelectorAll('nav button').forEach(b=>b.classList.remove('active')); |
| document.getElementById(`tab-${name}`).classList.add('active'); |
| btn.classList.add('active'); |
| if(name==='logs') loadLogs(); |
| if(name==='settings') loadSettings(); |
| } |
| |
| async function loadProviders() { |
| const r = await apiFetch('/api/providers'); |
| if(!r.ok) return; |
| const list = await r.json(); |
| const tb = document.getElementById('ptb'); |
| if(!list.length) { tb.innerHTML='<tr><td colspan="6" class="empty">No providers yet.</td></tr>'; return; } |
| tb.innerHTML = list.map(p=>`<tr> |
| <td><strong>${e(p.name)}</strong>${p.is_default?' <span class="pill pb">default</span>':''}</td> |
| <td class="mono" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${e(p.base_url)}">${e(p.base_url)}</td> |
| <td class="mono" style="color:var(--text-dim)">${e(p.key_preview)}</td> |
| <td><label class="toggle"><input type="checkbox" ${p.is_default?'checked':''} onchange="setDefault(${p.id},this.checked)"/><span class="slider"></span></label></td> |
| <td><label class="toggle"><input type="checkbox" ${p.enabled?'checked':''} onchange="setEnabled(${p.id},this.checked)"/><span class="slider"></span></label></td> |
| <td style="display:flex;gap:6px;flex-wrap:wrap"> |
| <button class="btn bs sm" onclick="editUrl(${p.id},'${e(p.base_url)}')">Edit URL</button> |
| <button class="btn bs sm" onclick="editKey(${p.id})">Edit Key</button> |
| <button class="btn bd sm" onclick="delProvider(${p.id},'${e(p.name)}')">Delete</button> |
| </td></tr>`).join(''); |
| } |
| |
| async function addProvider() { |
| const name=document.getElementById('an').value.trim(); |
| const url=document.getElementById('au').value.trim(); |
| const key=document.getElementById('ak').value.trim(); |
| if(!name||!url||!key){flash('All fields required','err');return;} |
| const r=await apiFetch('/api/providers',{method:'POST',body:{name,base_url:url,api_key:key}}); |
| if(r.ok){flash(`Provider "${name}" added`);document.getElementById('an').value='';document.getElementById('au').value='';document.getElementById('ak').value='';loadProviders();} |
| else{const d=await r.json().catch(()=>({}));flash(d.detail||'Failed','err');} |
| } |
| |
| async function delProvider(id,name){ |
| if(!confirm(`Delete "${name}"?`)) return; |
| const r=await apiFetch(`/api/providers/${id}`,{method:'DELETE'}); |
| if(r.ok){flash(`Deleted "${name}"`);loadProviders();}else flash('Delete failed','err'); |
| } |
| async function setEnabled(id,enabled){await apiFetch(`/api/providers/${id}`,{method:'PATCH',body:{enabled}});loadProviders();} |
| async function setDefault(id,is_default){await apiFetch(`/api/providers/${id}`,{method:'PATCH',body:{is_default}});loadProviders();loadSettings();} |
| async function editUrl(id,cur){const u=prompt('New Base URL:',cur);if(!u)return;const r=await apiFetch(`/api/providers/${id}`,{method:'PATCH',body:{base_url:u.trim()}});if(r.ok){flash('URL updated');loadProviders();}else flash('Failed','err');} |
| async function editKey(id){const k=prompt('New API Key:');if(!k)return;const r=await apiFetch(`/api/providers/${id}`,{method:'PATCH',body:{api_key:k.trim()}});if(r.ok){flash('Key updated');loadProviders();}else flash('Failed','err');} |
| |
| async function loadLogs(){ |
| const r=await apiFetch('/api/logs');if(!r.ok)return; |
| const logs=await r.json(); |
| const tb=document.getElementById('ltb'); |
| if(!logs.length){tb.innerHTML='<tr><td colspan="7" class="empty">No requests yet.</td></tr>';return;} |
| tb.innerHTML=logs.map(l=>{ |
| const ok=l.status_code&&l.status_code<400; |
| const ts=l.timestamp?new Date(l.timestamp+'Z').toLocaleTimeString():'β'; |
| return `<tr><td class="mono">${ts}</td><td><span class="pill pb">${e(l.provider_name||'β')}</span></td><td class="mono" style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${e(l.model||'β')}</td><td class="mono" style="color:var(--text-dim)">${e(l.path||'β')}</td><td><span class="pill ${ok?'pg':'pr'}">${l.status_code||'β'}</span></td><td class="mono">${l.latency_ms!=null?l.latency_ms+' ms':'β'}</td><td>${l.streaming?'<span class="pill py">SSE</span>':'β'}</td></tr>`; |
| }).join(''); |
| } |
| |
| async function loadSettings(){ |
| const r=await apiFetch('/api/settings/key');if(!r.ok)return; |
| const d=await r.json();fullKey=d.full_key; |
| document.getElementById('kd').textContent=d.key_preview; |
| |
| const secTitle = document.getElementById('sec-title'); |
| const secDesc = document.getElementById('sec-desc'); |
| const secIcon = document.getElementById('sec-icon'); |
| if(d.is_env) { |
| secIcon.textContent = 'π‘οΈ'; |
| secTitle.textContent = 'Master Key Secured via Space Secret'; |
| secTitle.style.color = 'var(--green)'; |
| secDesc.textContent = 'Loaded from MASTER_KEY environment variable. Fully protected from public log viewers.'; |
| } else { |
| secIcon.textContent = 'β οΈ'; |
| secTitle.textContent = 'Master Key Stored in DB (Log Exposure Risk)'; |
| secTitle.style.color = 'var(--yellow)'; |
| secDesc.textContent = 'On public Spaces, anyone viewing build logs can see auto-generated keys. Set a MASTER_KEY secret in Space Settings -> Variables and secrets.'; |
| } |
| |
| const pr=await apiFetch('/api/providers'); |
| if(pr.ok){const ps=await pr.json();const def=ps.find(p=>p.is_default); |
| document.getElementById('defp').textContent=def?`${def.name} β ${def.base_url}`:'No default set.';} |
| } |
| |
| function copyKey(){if(!fullKey)return;navigator.clipboard.writeText(fullKey).then(()=>flash('Key copied'));} |
| async function regenKey(){ |
| if(!confirm('Regenerate master key? All clients will break immediately.')) return; |
| const r=await apiFetch('/api/settings/key/regenerate',{method:'POST'}); |
| if(r.ok){const d=await r.json();fullKey=d.key;masterKey=d.key;sessionStorage.setItem('mk',d.key); |
| document.getElementById('kd').textContent=`...${d.key.slice(-8)}`;flash('Key regenerated β copy it now!');} |
| else flash('Failed','err'); |
| } |
| |
| async function updateStatus(){ |
| try{const r=await fetch('/health');if(r.ok){const d=await r.json(); |
| document.getElementById('hs').innerHTML=`<span style="color:var(--green)">β</span> ${d.providers} provider${d.providers!==1?'s':''}`;}} |
| catch{document.getElementById('hs').innerHTML='<span style="color:var(--red)">β</span> offline';} |
| } |
| |
| function e(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');} |
| function tv(id,btn){const el=document.getElementById(id);if(el.type==='password'){el.type='text';btn.textContent='hide';}else{el.type='password';btn.textContent='show';}} |
| function loadAll(){loadProviders();loadLogs();loadSettings();} |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def dashboard(): |
| return DASHBOARD_HTML |
|
|
|
|
| |
|
|
| class ProviderCreate(BaseModel): |
| name: str; base_url: str; api_key: str |
|
|
| class ProviderPatch(BaseModel): |
| base_url: str | None = None |
| api_key: str | None = None |
| enabled: bool | None = None |
| is_default: bool | None = None |
|
|
|
|
| @app.get("/api/providers", dependencies=[Depends(verify_key)]) |
| async def get_providers(request: Request): |
| cipher = request.app.state.cipher |
| async with request.app.state.db() as s: |
| res = await s.execute(select(Provider).order_by(Provider.id)) |
| ps = res.scalars().all() |
| out = [] |
| for p in ps: |
| dec = _decrypt(cipher, p.api_key) |
| out.append({ |
| "id": p.id, "name": p.name, "base_url": p.base_url, |
| "key_preview": f"...{dec[-4:]}" if len(dec)>=4 else "****", |
| "enabled": p.enabled, "is_default": p.is_default |
| }) |
| return out |
|
|
|
|
| @app.post("/api/providers", dependencies=[Depends(verify_key)]) |
| async def create_provider(body: ProviderCreate, request: Request): |
| cipher = request.app.state.cipher |
| enc_key = cipher.encrypt(body.api_key.encode()).decode() |
| async with request.app.state.db() as s: |
| ex = await s.execute(select(Provider).where(Provider.name == body.name)) |
| if ex.scalar(): raise HTTPException(400, f"Provider '{body.name}' already exists") |
| p = Provider(name=body.name, base_url=body.base_url, api_key=enc_key) |
| s.add(p); await s.commit(); await s.refresh(p) |
| return {"id": p.id, "name": p.name} |
|
|
|
|
| @app.patch("/api/providers/{pid}", dependencies=[Depends(verify_key)]) |
| async def patch_provider(pid: int, body: ProviderPatch, request: Request): |
| cipher = request.app.state.cipher |
| async with request.app.state.db() as s: |
| p = await s.get(Provider, pid) |
| if not p: raise HTTPException(404, "Not found") |
| if body.base_url is not None: p.base_url = body.base_url |
| if body.api_key is not None: p.api_key = cipher.encrypt(body.api_key.encode()).decode() |
| if body.enabled is not None: p.enabled = body.enabled |
| if body.is_default is not None: |
| if body.is_default: |
| await s.execute(update(Provider).where(Provider.id != pid).values(is_default=False)) |
| p.is_default = body.is_default |
| await s.commit() |
| return {"ok": True} |
|
|
|
|
| @app.delete("/api/providers/{pid}", dependencies=[Depends(verify_key)]) |
| async def delete_provider(pid: int, request: Request): |
| async with request.app.state.db() as s: |
| p = await s.get(Provider, pid) |
| if not p: raise HTTPException(404, "Not found") |
| await s.delete(p); await s.commit() |
| return {"ok": True} |
|
|
|
|
| |
|
|
| @app.get("/api/logs", dependencies=[Depends(verify_key)]) |
| async def get_logs(request: Request): |
| async with request.app.state.db() as s: |
| res = await s.execute(select(RequestLog).order_by(RequestLog.id.desc()).limit(200)) |
| logs = res.scalars().all() |
| return [{"id":l.id,"timestamp":l.timestamp.isoformat() if l.timestamp else None, |
| "provider_name":l.provider_name,"model":l.model,"status_code":l.status_code, |
| "latency_ms":round(l.latency_ms,1) if l.latency_ms else None, |
| "path":l.path,"streaming":l.streaming} for l in logs] |
|
|
|
|
| |
|
|
| @app.get("/api/settings/key", dependencies=[Depends(verify_key)]) |
| async def get_key(request: Request): |
| async with request.app.state.db() as s: |
| row = await s.get(Settings, "master_key") |
| k = row.value if row else "" |
| is_env = bool(os.environ.get("MASTER_KEY") or os.environ.get("MASTER_API_KEY") or os.environ.get("PROXY_MASTER_KEY")) |
| return { |
| "key_preview": f"...{k[-8:]}" if len(k)>=8 else "****", |
| "full_key": k, |
| "is_env": is_env |
| } |
|
|
|
|
| @app.post("/api/settings/key/regenerate", dependencies=[Depends(verify_key)]) |
| async def regen_key(request: Request): |
| new_key = f"umk-{secrets.token_urlsafe(32)}" |
| async with request.app.state.db() as s: |
| row = await s.get(Settings, "master_key") |
| if row: row.value = new_key |
| else: s.add(Settings(key="master_key", value=new_key)) |
| await s.commit() |
| return {"key": new_key} |
|
|
|
|
| |
|
|
| async def _log(session, provider_name, model, status_code, latency_ms, path, streaming): |
| session.add(RequestLog(timestamp=datetime.utcnow(), provider_name=provider_name, |
| model=model, status_code=status_code, latency_ms=latency_ms, |
| path=path, streaming=streaming)) |
| await session.commit() |
| |
| res = await session.execute(select(RequestLog.id).order_by(RequestLog.id.desc()).offset(500)) |
| old = res.scalars().all() |
| if old: |
| await session.execute(delete(RequestLog).where(RequestLog.id.in_(old))) |
| await session.commit() |
|
|
|
|
| def _parse_provider(model: str, providers): |
| if "/" in model: |
| prefix, real = model.split("/", 1) |
| for p in providers: |
| if p.name.lower() == prefix.lower(): |
| return p, real |
| return None, model |
|
|
|
|
| |
|
|
| @app.get("/v1/models/{model_id:path}", dependencies=[Depends(verify_key)]) |
| async def retrieve_model(request: Request, model_id: str): |
| async with request.app.state.db() as s: |
| res = await s.execute(select(Provider).where(Provider.enabled == True)) |
| providers = res.scalars().all() |
| provider, _ = _parse_provider(model_id, providers) |
| pname = provider.name if provider else "proxy" |
| return JSONResponse( |
| { |
| "id": model_id, |
| "object": "model", |
| "created": int(time.time()), |
| "owned_by": pname |
| }, |
| headers={"x-request-id": f"req_{uuid.uuid4().hex}"} |
| ) |
|
|
|
|
| @app.get("/v1/models", dependencies=[Depends(verify_key)]) |
| async def list_models(request: Request): |
| async with request.app.state.db() as s: |
| res = await s.execute(select(Provider).where(Provider.enabled == True)) |
| providers = res.scalars().all() |
| all_models = [] |
| async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: |
| for p in providers: |
| dec_key = _decrypt(request.app.state.cipher, p.api_key) |
| try: |
| r = await client.get(f"{p.base_url.rstrip('/')}/models", |
| headers={"Authorization": f"Bearer {dec_key}"}) |
| if r.status_code == 200: |
| for m in r.json().get("data", []): |
| m["id"] = f"{p.name.lower()}/{m['id']}" |
| all_models.append(m) |
| except Exception: |
| pass |
| return JSONResponse( |
| {"object": "list", "data": all_models}, |
| headers={"x-request-id": f"req_{uuid.uuid4().hex}"} |
| ) |
|
|
|
|
| |
|
|
| @app.api_route("/v1/{path:path}", methods=["GET","POST","PUT","DELETE","PATCH"], |
| dependencies=[Depends(verify_key)]) |
| async def proxy(request: Request, path: str): |
| body_bytes = await request.body() |
| body_json = None |
| model_raw = "" |
| if body_bytes: |
| try: |
| body_json = json.loads(body_bytes) |
| model_raw = body_json.get("model", "") |
| except Exception: |
| pass |
|
|
| async with request.app.state.db() as s: |
| res = await s.execute(select(Provider).where(Provider.enabled == True)) |
| providers = res.scalars().all() |
| if not providers: |
| raise HTTPException(503, "No enabled AI providers configured.") |
|
|
| provider, real_model = _parse_provider(model_raw, providers) |
| if provider is None: |
| for p in providers: |
| if p.is_default: provider = p; break |
| if provider is None: |
| provider = providers[0] |
|
|
| if body_json is not None and model_raw: |
| body_json["model"] = real_model |
| body_bytes = json.dumps(body_json).encode() |
|
|
| dec_api_key = _decrypt(request.app.state.cipher, provider.api_key) |
| is_streaming = bool(body_json and body_json.get("stream")) |
| target_url = f"{provider.base_url.rstrip('/')}/{path.lstrip('/')}" |
| req_id = f"req_{uuid.uuid4().hex}" |
|
|
| skip_headers = {"host", "authorization", "api-key", "content-length"} |
| forward_headers = {k: v for k, v in request.headers.items() if k.lower() not in skip_headers} |
| forward_headers["Authorization"] = f"Bearer {dec_api_key}" |
| forward_headers["Content-Type"] = request.headers.get("Content-Type", "application/json") |
|
|
| start = time.time() |
|
|
| if is_streaming: |
| logged = False |
| async def generate() -> AsyncGenerator[bytes, None]: |
| nonlocal logged |
| try: |
| async with httpx.AsyncClient(timeout=TIMEOUT) as client: |
| async with client.stream(request.method, target_url, |
| headers=forward_headers, content=body_bytes) as resp: |
| latency = (time.time() - start) * 1000 |
| if not logged: |
| logged = True |
| async with request.app.state.db() as ls: |
| await _log(ls, provider.name, real_model, |
| resp.status_code, latency, path, True) |
| async for chunk in resp.aiter_bytes(): |
| yield chunk |
| except Exception as e: |
| err_chunk = json.dumps({"error": {"message": str(e), "type": "api_error", "code": 502}}).encode() |
| yield b"data: " + err_chunk + b"\n\n" |
| yield b"data: [DONE]\n\n" |
|
|
| return StreamingResponse( |
| generate(), |
| media_type="text/event-stream", |
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "x-request-id": req_id} |
| ) |
|
|
| try: |
| async with httpx.AsyncClient(timeout=TIMEOUT) as client: |
| resp = await client.request(request.method, target_url, |
| headers=forward_headers, content=body_bytes) |
| latency = (time.time() - start) * 1000 |
| async with request.app.state.db() as s: |
| await _log(s, provider.name, real_model, resp.status_code, latency, path, False) |
|
|
| resp_headers = {"x-request-id": req_id} |
| content_type = resp.headers.get("content-type", "application/json") |
| if "json" in content_type: |
| return JSONResponse(content=resp.json(), status_code=resp.status_code, headers=resp_headers) |
| return Response(content=resp.content, status_code=resp.status_code, media_type=content_type, headers=resp_headers) |
| except httpx.TimeoutException: |
| raise HTTPException(504, "Upstream AI provider connection timed out.") |
| except Exception as e: |
| raise HTTPException(502, f"Proxy error communicating with upstream provider: {e}") |
|
|