CJHauser commited on
Commit
298a80f
Β·
verified Β·
1 Parent(s): 21a139f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +501 -869
app.py CHANGED
@@ -1,736 +1,429 @@
1
- """
2
- Unified AI Proxy β€” main.py
3
- Single-file FastAPI app: proxy + dashboard + auth + DB
4
- """
5
-
6
  import os
7
- import secrets
 
8
  import time
9
- import logging
10
  from contextlib import asynccontextmanager
11
  from datetime import datetime
12
- from typing import AsyncGenerator, Optional
13
 
14
  import httpx
15
- from fastapi import FastAPI, Request, HTTPException, Depends
16
  from fastapi.middleware.cors import CORSMiddleware
17
- from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
18
- from fastapi.staticfiles import StaticFiles
19
  from sqlalchemy import (
20
- create_engine, Column, String, Boolean, Integer, DateTime, Float, Text
 
21
  )
22
- from sqlalchemy.orm import declarative_base, sessionmaker, Session
23
- from sqlalchemy.sql import func
24
 
25
- logging.basicConfig(level=logging.INFO)
26
- logger = logging.getLogger(__name__)
27
 
28
- # ---------------------------------------------------------------------------
29
- # Database setup
30
- # ---------------------------------------------------------------------------
 
 
 
31
 
32
- _DATA_DIR = "/data" if os.path.isdir("/data") else "."
33
- DATABASE_URL = f"sqlite:///{_DATA_DIR}/db.sqlite"
34
 
35
- engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
36
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
37
- Base = declarative_base()
38
 
39
 
40
  class Provider(Base):
41
  __tablename__ = "providers"
42
- id = Column(Integer, primary_key=True, index=True)
43
- name = Column(String, unique=True, nullable=False)
44
- base_url = Column(String, nullable=False)
45
- api_key = Column(String, nullable=False)
46
- enabled = Column(Boolean, default=True)
47
  is_default = Column(Boolean, default=False)
48
  created_at = Column(DateTime, default=datetime.utcnow)
49
 
50
 
51
- class Setting(Base):
52
- __tablename__ = "settings"
53
- key = Column(String, primary_key=True)
54
- value = Column(String, nullable=False)
55
-
56
-
57
  class RequestLog(Base):
58
  __tablename__ = "request_logs"
59
- id = Column(Integer, primary_key=True, index=True)
60
- timestamp = Column(DateTime, default=datetime.utcnow)
61
- provider = Column(String)
62
- model = Column(String)
63
- status_code = Column(Integer)
64
- latency_ms = Column(Float)
65
- endpoint = Column(String)
66
-
67
 
68
- Base.metadata.create_all(bind=engine)
69
 
70
-
71
- def get_db():
72
- db = SessionLocal()
73
- try:
74
- yield db
75
- finally:
76
- db.close()
77
-
78
-
79
- # ---------------------------------------------------------------------------
80
- # Auth helpers
81
- # ---------------------------------------------------------------------------
82
-
83
- def get_master_key(db: Session) -> str:
84
- row = db.query(Setting).filter(Setting.key == "master_key").first()
85
- if row:
86
- return row.value
87
- new_key = "sk-proxy-" + secrets.token_urlsafe(32)
88
- db.add(Setting(key="master_key", value=new_key))
89
- db.commit()
90
- logger.info("=" * 60)
91
- logger.info(f" MASTER API KEY: {new_key}")
92
- logger.info(" Save this β€” it won't be shown again.")
93
- logger.info("=" * 60)
94
- return new_key
95
-
96
-
97
- def verify_master_key(request: Request, db: Session = Depends(get_db)) -> bool:
98
- auth = request.headers.get("Authorization", "")
99
- if not auth.startswith("Bearer "):
100
- raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
101
- token = auth[len("Bearer "):]
102
- master = get_master_key(db)
103
- if not secrets.compare_digest(token, master):
104
- raise HTTPException(status_code=401, detail="Invalid master API key")
105
- return True
106
-
107
-
108
- # ---------------------------------------------------------------------------
109
- # Provider routing helpers
110
- # ---------------------------------------------------------------------------
111
-
112
- def parse_model(model_str: str, db: Session):
113
- """
114
- Split 'providerName/real-model-name' into (provider_row, real_model).
115
- If no slash prefix, return (default_provider, model_str).
116
- Handles nested slashes like 'together/meta-llama/Llama-3-70b'.
117
- """
118
- if "/" in model_str:
119
- prefix, rest = model_str.split("/", 1)
120
- provider = (
121
- db.query(Provider)
122
- .filter(func.lower(Provider.name) == prefix.lower(), Provider.enabled == True)
123
- .first()
124
- )
125
- if provider:
126
- return provider, rest
127
-
128
- # fallback to default
129
- provider = db.query(Provider).filter(Provider.is_default == True, Provider.enabled == True).first()
130
- if not provider:
131
- provider = db.query(Provider).filter(Provider.enabled == True).first()
132
- if not provider:
133
- raise HTTPException(status_code=503, detail="No enabled providers configured")
134
- return provider, model_str
135
-
136
-
137
- def log_request(db: Session, provider_name: str, model: str, status: int, latency: float, endpoint: str):
138
- db.add(RequestLog(
139
- provider=provider_name,
140
- model=model,
141
- status_code=status,
142
- latency_ms=latency,
143
- endpoint=endpoint,
144
- ))
145
- db.commit()
146
- # Truncate to 500 rows
147
- count = db.query(RequestLog).count()
148
- if count > 500:
149
- oldest_ids = (
150
- db.query(RequestLog.id)
151
- .order_by(RequestLog.timestamp.asc())
152
- .limit(count - 500)
153
- .all()
154
- )
155
- ids = [r.id for r in oldest_ids]
156
- db.query(RequestLog).filter(RequestLog.id.in_(ids)).delete(synchronize_session=False)
157
- db.commit()
158
-
159
-
160
- # ---------------------------------------------------------------------------
161
- # Startup: seed providers from env vars
162
- # ---------------------------------------------------------------------------
163
-
164
- def seed_providers_from_env(db: Session):
165
- """
166
- Auto-import providers from PROVIDER_<NAME>_URL + PROVIDER_<NAME>_KEY env vars.
167
- """
168
- seen: dict[str, dict] = {}
169
- for key, val in os.environ.items():
170
- if key.startswith("PROVIDER_") and key.endswith("_URL"):
171
- name = key[len("PROVIDER_"):-len("_URL")].replace("_", " ").title()
172
- seen.setdefault(name, {})["url"] = val
173
- elif key.startswith("PROVIDER_") and key.endswith("_KEY"):
174
- name = key[len("PROVIDER_"):-len("_KEY")].replace("_", " ").title()
175
- seen.setdefault(name, {})["key"] = val
176
-
177
- for name, cfg in seen.items():
178
- if "url" not in cfg or "key" not in cfg:
179
- continue
180
- exists = db.query(Provider).filter(func.lower(Provider.name) == name.lower()).first()
181
- if not exists:
182
- db.add(Provider(name=name, base_url=cfg["url"], api_key=cfg["key"]))
183
- logger.info(f"Seeded provider '{name}' from environment")
184
- db.commit()
185
-
186
-
187
- # ---------------------------------------------------------------------------
188
- # Lifespan
189
- # ---------------------------------------------------------------------------
190
 
191
  @asynccontextmanager
192
- async def lifespan(app: FastAPI) -> AsyncGenerator:
193
- db = SessionLocal()
194
- try:
195
- get_master_key(db)
196
- seed_providers_from_env(db)
197
- finally:
198
- db.close()
199
  yield
 
200
 
201
 
202
- # ---------------------------------------------------------------------------
203
- # App
204
- # ---------------------------------------------------------------------------
205
-
206
  app = FastAPI(title="Unified AI Proxy", lifespan=lifespan)
 
207
 
208
- app.add_middleware(
209
- CORSMiddleware,
210
- allow_origins=["*"],
211
- allow_credentials=True,
212
- allow_methods=["*"],
213
- allow_headers=["*"],
214
- )
 
 
 
 
 
 
 
 
215
 
 
 
 
 
 
 
216
 
217
- # ---------------------------------------------------------------------------
218
- # Dashboard (served from embedded HTML)
219
- # ---------------------------------------------------------------------------
220
 
221
  DASHBOARD_HTML = r"""<!DOCTYPE html>
222
  <html lang="en">
223
  <head>
224
  <meta charset="UTF-8"/>
225
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
226
  <title>Unified AI Proxy</title>
227
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
228
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
229
  <style>
230
- *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
231
- :root{
232
- --bg:#0d1117;--surface:#161b22;--border:#30363d;
233
- --accent:#58a6ff;--accent-dim:#1f6feb;
234
- --text:#e6edf3;--muted:#8b949e;--danger:#f85149;
235
- --success:#3fb950;--warning:#d29922;
236
- --mono:'JetBrains Mono',monospace;--sans:'Inter',sans-serif;
237
- }
238
- body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;min-height:100vh}
239
- a{color:var(--accent);text-decoration:none}
240
-
241
- /* Layout */
242
- .topbar{display:flex;align-items:center;gap:12px;padding:14px 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100}
243
- .topbar .logo{font-family:var(--mono);font-size:15px;font-weight:500;color:var(--accent)}
244
- .topbar .subtitle{color:var(--muted);font-size:12px}
245
- .topbar .spacer{flex:1}
246
- .status-dot{width:8px;height:8px;border-radius:50%;background:var(--success)}
247
-
248
- .tabs{display:flex;gap:2px;padding:0 24px;background:var(--surface);border-bottom:1px solid var(--border)}
249
- .tab{padding:10px 18px;cursor:pointer;color:var(--muted);font-size:13px;font-weight:500;border-bottom:2px solid transparent;transition:all .15s}
250
- .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
251
- .tab:hover:not(.active){color:var(--text)}
252
-
253
- .main{padding:24px;max-width:1100px;margin:0 auto}
254
- .panel{display:none}.panel.active{display:block}
255
-
256
- /* Cards */
257
- .card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:20px;margin-bottom:16px}
258
- .card-title{font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:16px}
259
-
260
- /* Forms */
261
- .form-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:12px}
262
- label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px;font-weight:500}
263
- input[type=text],input[type=password],input[type=url]{
264
- width:100%;background:#0d1117;border:1px solid var(--border);border-radius:6px;
265
- padding:8px 10px;color:var(--text);font-family:var(--mono);font-size:13px;outline:none;
266
- transition:border-color .15s
267
- }
268
- input:focus{border-color:var(--accent)}
269
- .input-wrap{position:relative}
270
- .input-wrap input{padding-right:36px}
271
- .toggle-vis{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px 4px}
272
- .toggle-vis:hover{color:var(--text)}
273
-
274
- /* Buttons */
275
- .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all .15s}
276
- .btn-primary{background:var(--accent-dim);color:#fff}.btn-primary:hover{background:var(--accent)}
277
- .btn-ghost{background:transparent;color:var(--muted);border:1px solid var(--border)}.btn-ghost:hover{color:var(--text);border-color:var(--text)}
278
- .btn-danger{background:transparent;color:var(--danger);border:1px solid var(--danger)}.btn-danger:hover{background:var(--danger);color:#fff}
279
- .btn-sm{padding:4px 10px;font-size:12px}
280
- .btn:disabled{opacity:.5;cursor:not-allowed}
281
-
282
- /* Table */
283
- .table-wrap{overflow-x:auto}
284
- table{width:100%;border-collapse:collapse;font-size:13px}
285
- th{text-align:left;padding:8px 12px;color:var(--muted);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}
286
- td{padding:10px 12px;border-bottom:1px solid #21262d;vertical-align:middle}
287
- tr:last-child td{border-bottom:none}
288
- tr:hover td{background:rgba(255,255,255,.02)}
289
- .mono{font-family:var(--mono);font-size:12px}
290
- .badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
291
- .badge-green{background:#0d1f12;color:var(--success);border:1px solid #1a3a1f}
292
- .badge-red{background:#1f0d0d;color:var(--danger);border:1px solid #3a1a1a}
293
- .badge-blue{background:#0d1420;color:var(--accent);border:1px solid #1a2a40}
294
-
295
- /* Toggle switch */
296
- .toggle{position:relative;display:inline-block;width:36px;height:20px}
297
- .toggle input{opacity:0;width:0;height:0}
298
- .slider{position:absolute;cursor:pointer;inset:0;background:#30363d;border-radius:20px;transition:.2s}
299
- .slider::before{content:'';position:absolute;width:14px;height:14px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.2s}
300
- input:checked+.slider{background:var(--accent-dim)}
301
- input:checked+.slider::before{transform:translateX(16px)}
302
-
303
- /* Login */
304
- .login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)}
305
- .login-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:40px;width:380px}
306
- .login-box h1{font-family:var(--mono);font-size:18px;color:var(--accent);margin-bottom:8px}
307
- .login-box p{color:var(--muted);font-size:13px;margin-bottom:24px}
308
-
309
- /* Hint box */
310
- .hint{background:#0d1420;border:1px solid #1a2a40;border-radius:8px;overflow:hidden;margin-bottom:16px}
311
- .hint-header{display:flex;align-items:center;gap:8px;padding:10px 16px;cursor:pointer;user-select:none}
312
- .hint-header span{color:var(--accent);font-size:13px;font-weight:500;flex:1}
313
- .hint-chevron{color:var(--muted);transition:.2s;font-size:11px}
314
- .hint-body{padding:0 16px 16px;display:none}
315
- .hint-body.open{display:block}
316
- pre{background:#080d14;border:1px solid var(--border);border-radius:6px;padding:12px;overflow-x:auto;font-family:var(--mono);font-size:12px;color:#a5d6ff;line-height:1.6;margin-top:8px}
317
-
318
- /* Alerts */
319
- .alert{padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:12px}
320
- .alert-error{background:#1f0d0d;border:1px solid var(--danger);color:var(--danger)}
321
- .alert-success{background:#0d1f12;border:1px solid var(--success);color:var(--success)}
322
-
323
- /* Settings */
324
- .setting-row{display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid var(--border)}
325
- .setting-row:last-child{border-bottom:none}
326
- .setting-info{flex:1}
327
- .setting-info h3{font-size:14px;font-weight:500;margin-bottom:2px}
328
- .setting-info p{font-size:12px;color:var(--muted)}
329
- .key-display{font-family:var(--mono);font-size:12px;background:#080d14;border:1px solid var(--border);border-radius:6px;padding:8px 12px;color:var(--accent);flex:1;word-break:break-all}
330
-
331
- .empty-state{text-align:center;padding:40px;color:var(--muted)}
332
- .empty-state .icon{font-size:32px;margin-bottom:12px}
333
-
334
- /* Log table specifics */
335
- .latency{color:var(--warning)}
336
- .status-2{color:var(--success)}.status-4{color:var(--danger)}.status-5{color:var(--danger)}
337
-
338
- #toast{position:fixed;bottom:24px;right:24px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 18px;font-size:13px;transform:translateY(80px);opacity:0;transition:.3s;z-index:999;pointer-events:none}
339
- #toast.show{transform:translateY(0);opacity:1}
340
  </style>
341
  </head>
342
  <body>
343
-
344
- <div id="login-screen" style="display:none">
345
- <div class="login-wrap">
346
- <div class="login-box">
347
- <h1>πŸ”€ AI Proxy</h1>
348
- <p>Enter your master API key to access the dashboard.</p>
349
- <div style="margin-bottom:12px">
350
- <label>Master API Key</label>
351
- <div class="input-wrap">
352
- <input type="password" id="login-key" placeholder="sk-proxy-..." onkeydown="if(event.key==='Enter')doLogin()"/>
353
- <button class="toggle-vis" onclick="toggleVis('login-key')">πŸ‘</button>
354
- </div>
355
- </div>
356
- <div id="login-error" class="alert alert-error" style="display:none"></div>
357
- <button class="btn btn-primary" style="width:100%" onclick="doLogin()">Unlock Dashboard</button>
358
  </div>
 
 
359
  </div>
360
  </div>
361
 
362
- <div id="app-screen" style="display:none">
363
- <div class="topbar">
364
- <span class="logo">πŸ”€ Unified AI Proxy</span>
365
- <span class="subtitle">OpenAI-compatible multi-provider gateway</span>
 
 
 
 
366
  <div class="spacer"></div>
367
- <div class="status-dot" id="status-dot"></div>
368
- <span style="font-size:12px;color:var(--muted)" id="status-label">checking...</span>
369
- <button class="btn btn-ghost btn-sm" onclick="doLogout()">Logout</button>
370
- </div>
371
-
372
- <div class="tabs">
373
- <div class="tab active" onclick="switchTab('providers',this)">Providers</div>
374
- <div class="tab" onclick="switchTab('logs',this)">Logs</div>
375
- <div class="tab" onclick="switchTab('settings',this)">Settings</div>
376
- </div>
377
-
378
- <div class="main">
379
 
380
  <!-- PROVIDERS -->
381
- <div id="panel-providers" class="panel active">
382
- <div class="card">
383
- <div class="card-title">Add Provider</div>
384
- <div class="form-row">
385
- <div>
386
- <label>Name</label>
387
- <input type="text" id="p-name" placeholder="Groq"/>
388
- </div>
389
- <div>
390
- <label>Base URL</label>
391
- <input type="url" id="p-url" placeholder="https://api.groq.com/openai/v1"/>
392
- </div>
393
- <div>
394
- <label>API Key</label>
395
- <div class="input-wrap">
396
- <input type="password" id="p-key" placeholder="sk-..."/>
397
- <button class="toggle-vis" onclick="toggleVis('p-key')">πŸ‘</button>
398
- </div>
399
- </div>
400
  </div>
401
- <div id="add-error" class="alert alert-error" style="display:none"></div>
402
- <button class="btn btn-primary" onclick="addProvider()">+ Add Provider</button>
403
- </div>
404
-
405
- <div class="hint">
406
- <div class="hint-header" onclick="toggleHint()">
407
- <span>πŸ“– Usage examples</span>
408
- <span class="hint-chevron" id="hint-chevron">β–Ό</span>
409
- </div>
410
- <div class="hint-body" id="hint-body">
411
- <p style="color:var(--muted);font-size:12px">Route to a specific provider by prefixing the model name with <code style="color:var(--accent)">providerName/</code>:</p>
412
- <pre id="hint-pre">Loading...</pre>
413
  </div>
414
  </div>
415
-
416
  <div class="card">
417
- <div class="card-title">Configured Providers</div>
418
- <div class="table-wrap">
419
- <table>
420
- <thead><tr>
421
- <th>Name</th><th>Base URL</th><th>API Key</th><th>Default</th><th>Enabled</th><th>Actions</th>
422
- </tr></thead>
423
- <tbody id="providers-tbody">
424
- <tr><td colspan="6" class="empty-state"><div class="icon">πŸ”Œ</div><div>No providers yet. Add one above.</div></td></tr>
425
- </tbody>
426
- </table>
427
  </div>
 
 
 
 
 
 
 
428
  </div>
429
  </div>
430
 
431
  <!-- LOGS -->
432
- <div id="panel-logs" class="panel">
433
- <div class="card">
434
- <div class="card-title" style="display:flex;align-items:center;gap:8px">
435
- Request Logs
436
- <span style="color:var(--muted);font-weight:400;font-size:11px">Last 200 Β· auto-refresh 5s</span>
437
- <div class="spacer"></div>
438
- <button class="btn btn-ghost btn-sm" onclick="loadLogs()">↻ Refresh</button>
439
- </div>
440
- <div class="table-wrap">
441
- <table>
442
- <thead><tr>
443
- <th>Time</th><th>Provider</th><th>Model</th><th>Endpoint</th><th>Status</th><th>Latency</th>
444
- </tr></thead>
445
- <tbody id="logs-tbody">
446
- <tr><td colspan="6" class="empty-state"><div>No requests logged yet.</div></td></tr>
447
- </tbody>
448
- </table>
449
- </div>
450
  </div>
451
  </div>
452
 
453
  <!-- SETTINGS -->
454
- <div id="panel-settings" class="panel">
455
  <div class="card">
456
- <div class="card-title">Settings</div>
457
- <div class="setting-row">
458
- <div class="setting-info">
459
- <h3>Master API Key</h3>
460
- <p>Use this as the Bearer token for all proxy requests.</p>
461
- </div>
462
- <div class="key-display" id="master-key-display">β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’</div>
463
- <button class="btn btn-ghost btn-sm" onclick="toggleShowKey()">Show</button>
464
- <button class="btn btn-ghost btn-sm" onclick="copyKey()">Copy</button>
465
- </div>
466
- <div class="setting-row">
467
- <div class="setting-info">
468
- <h3>Regenerate Master Key</h3>
469
- <p>Invalidates the current key immediately. You'll be logged out.</p>
470
- </div>
471
- <button class="btn btn-danger btn-sm" onclick="regenKey()">Regenerate</button>
472
- </div>
473
- <div class="setting-row">
474
- <div class="setting-info">
475
- <h3>Default Provider</h3>
476
- <p>Used when no <code style="color:var(--accent)">providerName/</code> prefix is given.</p>
477
- </div>
478
- <select id="default-provider-select" onchange="setDefault(this.value)"
479
- style="background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:6px 10px;color:var(--text);font-family:var(--mono);font-size:13px">
480
- <option value="">Loading...</option>
481
- </select>
482
  </div>
 
 
 
 
 
 
 
 
 
 
483
  </div>
484
  </div>
485
 
486
- </div>
487
  </div>
488
-
489
- <div id="toast"></div>
490
-
491
  <script>
492
- const API = '';
493
- let masterKey = '';
494
- let keyVisible = false;
495
- let logsInterval = null;
496
-
497
- // ---------- Auth ----------
498
- function getKey(){return sessionStorage.getItem('master_key')||''}
499
- function setKey(k){sessionStorage.setItem('master_key',k)}
500
- function clearKey(){sessionStorage.removeItem('master_key')}
501
-
502
- function headers(extra={}){return{Authorization:'Bearer '+getKey(),'Content-Type':'application/json',...extra}}
503
-
504
- async function doLogin(){
505
- const k=document.getElementById('login-key').value.trim();
506
- if(!k)return;
507
- setKey(k);
508
- const ok=await checkAuth();
509
- if(ok){showApp()}
510
- else{clearKey();document.getElementById('login-error').textContent='Invalid key';document.getElementById('login-error').style.display='block'}
511
  }
512
 
513
- async function checkAuth(){
514
- try{const r=await fetch('/api/me',{headers:headers()});return r.ok}catch{return false}
 
 
 
 
515
  }
 
516
 
517
- function doLogout(){clearKey();location.reload()}
518
-
519
- function showApp(){
520
  document.getElementById('login-screen').style.display='none';
521
- document.getElementById('app-screen').style.display='block';
522
- init();
 
523
  }
524
 
525
- window.onload=async()=>{
526
- if(getKey()&&await checkAuth()){showApp()}
527
- else{document.getElementById('login-screen').style.display='block'}
528
- }
529
 
530
- // ---------- Init ----------
531
- function init(){
532
- loadProviders();
533
- loadStatus();
534
- buildHint();
535
- setInterval(loadStatus,15000);
536
  }
537
 
538
- // ---------- Tabs ----------
539
- function switchTab(name,el){
540
- document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
541
  document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
542
- document.getElementById('panel-'+name).classList.add('active');
543
- el.classList.add('active');
544
- if(name==='logs'){loadLogs();if(!logsInterval)logsInterval=setInterval(loadLogs,5000)}
545
- else{clearInterval(logsInterval);logsInterval=null}
546
- if(name==='settings')loadSettings();
547
- }
548
-
549
- // ---------- Status ----------
550
- async function loadStatus(){
551
- try{
552
- const r=await fetch('/health');
553
- const d=await r.json();
554
- document.getElementById('status-dot').style.background=r.ok?'var(--success)':'var(--danger)';
555
- document.getElementById('status-label').textContent=`${d.providers} provider${d.providers!==1?'s':''} active`;
556
- }catch{document.getElementById('status-dot').style.background='var(--danger)'}
557
  }
558
 
559
- // ---------- Providers ----------
560
- async function loadProviders(){
561
- const r=await fetch('/api/providers',{headers:headers()});
562
- if(!r.ok)return;
563
- const providers=await r.json();
564
- renderProviders(providers);
565
- // also update settings dropdown
566
- const sel=document.getElementById('default-provider-select');
567
- sel.innerHTML='<option value="">None</option>'+providers.map(p=>`<option value="${p.id}" ${p.is_default?'selected':''}>${p.name}</option>`).join('');
 
 
 
 
 
 
 
 
568
  }
569
 
570
- function renderProviders(providers){
571
- const tb=document.getElementById('providers-tbody');
572
- if(!providers.length){
573
- tb.innerHTML='<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--muted)"><div style="font-size:32px;margin-bottom:12px">πŸ”Œ</div><div>No providers yet. Add one above.</div></td></tr>';
574
- return;
575
- }
576
- tb.innerHTML=providers.map(p=>`
577
- <tr>
578
- <td><span class="mono">${esc(p.name)}</span>${p.is_default?'<span class="badge badge-blue" style="margin-left:8px">default</span>':''}</td>
579
- <td><span class="mono" style="color:var(--muted)">${esc(p.base_url)}</span></td>
580
- <td><span class="mono" style="color:var(--muted)">β€’β€’β€’β€’${esc(p.key_tail)}</span></td>
581
- <td>
582
- <label class="toggle"><input type="checkbox" ${p.is_default?'checked':''} onchange="setProviderDefault(${p.id})"/><span class="slider"></span></label>
583
- </td>
584
- <td>
585
- <label class="toggle"><input type="checkbox" ${p.enabled?'checked':''} onchange="toggleProvider(${p.id},this.checked)"/><span class="slider"></span></label>
586
- </td>
587
- <td style="display:flex;gap:6px">
588
- <button class="btn btn-ghost btn-sm" onclick="editProvider(${p.id},'${esc(p.name)}','${esc(p.base_url)}')">Edit</button>
589
- <button class="btn btn-danger btn-sm" onclick="deleteProvider(${p.id})">Delete</button>
590
- </td>
591
- </tr>
592
- `).join('');
593
  }
594
 
595
- async function addProvider(){
596
- const name=document.getElementById('p-name').value.trim();
597
- const url=document.getElementById('p-url').value.trim();
598
- const key=document.getElementById('p-key').value.trim();
599
- const errEl=document.getElementById('add-error');
600
- errEl.style.display='none';
601
- if(!name||!url||!key){errEl.textContent='All fields required';errEl.style.display='block';return}
602
- const r=await fetch('/api/providers',{method:'POST',headers:headers(),body:JSON.stringify({name,base_url:url,api_key:key})});
603
- if(!r.ok){const e=await r.json();errEl.textContent=e.detail||'Error';errEl.style.display='block';return}
604
- document.getElementById('p-name').value='';
605
- document.getElementById('p-url').value='';
606
- document.getElementById('p-key').value='';
607
- toast('Provider added βœ“');
608
- loadProviders();
609
  }
 
 
 
 
610
 
611
- async function deleteProvider(id){
612
- if(!confirm('Delete this provider?'))return;
613
- await fetch(`/api/providers/${id}`,{method:'DELETE',headers:headers()});
614
- loadProviders();
615
- toast('Provider deleted');
616
- }
617
-
618
- async function toggleProvider(id,enabled){
619
- await fetch(`/api/providers/${id}`,{method:'PATCH',headers:headers(),body:JSON.stringify({enabled})});
620
- loadProviders();
621
- }
622
-
623
- async function setProviderDefault(id){
624
- await fetch(`/api/providers/${id}`,{method:'PATCH',headers:headers(),body:JSON.stringify({is_default:true})});
625
- loadProviders();
626
- toast('Default provider updated');
627
- }
628
-
629
- function editProvider(id,name,url){
630
- const newUrl=prompt('Base URL:',url);
631
- if(newUrl===null)return;
632
- const newKey=prompt('New API Key (leave blank to keep current):','');
633
- const body={base_url:newUrl};
634
- if(newKey)body.api_key=newKey;
635
- fetch(`/api/providers/${id}`,{method:'PATCH',headers:headers(),body:JSON.stringify(body)})
636
- .then(()=>{loadProviders();toast('Provider updated')});
637
- }
638
-
639
- // ---------- Logs ----------
640
  async function loadLogs(){
641
- const r=await fetch('/api/logs',{headers:headers()});
642
- if(!r.ok)return;
643
  const logs=await r.json();
644
- const tb=document.getElementById('logs-tbody');
645
- if(!logs.length){tb.innerHTML='<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--muted)">No requests logged yet.</td></tr>';return}
646
  tb.innerHTML=logs.map(l=>{
647
- const sc=l.status_code;
648
- const cls=sc<300?'status-2':sc<500?'status-4':'status-5';
649
- const ts=new Date(l.timestamp+'Z').toLocaleTimeString();
650
- return`<tr>
651
- <td class="mono" style="color:var(--muted);font-size:11px">${ts}</td>
652
- <td><span class="badge badge-blue">${esc(l.provider||'?')}</span></td>
653
- <td class="mono" style="font-size:11px">${esc(l.model||'')}</td>
654
- <td class="mono" style="color:var(--muted);font-size:11px">${esc(l.endpoint||'')}</td>
655
- <td class="${cls} mono">${sc}</td>
656
- <td class="latency mono">${l.latency_ms?l.latency_ms.toFixed(0)+'ms':'β€”'}</td>
657
- </tr>`;
658
  }).join('');
659
  }
660
 
661
- // ---------- Settings ----------
662
  async function loadSettings(){
663
- const r=await fetch('/api/settings',{headers:headers()});
664
- if(!r.ok)return;
665
- const d=await r.json();
666
- masterKey=d.master_key;
667
- document.getElementById('master-key-display').textContent=keyVisible?masterKey:'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’';
668
- }
669
-
670
- async function setDefault(id){
671
- if(!id)return;
672
- await fetch(`/api/providers/${id}`,{method:'PATCH',headers:headers(),body:JSON.stringify({is_default:true})});
673
- loadProviders();
674
- toast('Default provider updated');
675
- }
676
-
677
- function toggleShowKey(){
678
- keyVisible=!keyVisible;
679
- document.getElementById('master-key-display').textContent=keyVisible?masterKey:'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’';
680
- }
681
-
682
- function copyKey(){
683
- navigator.clipboard.writeText(masterKey).then(()=>toast('Key copied to clipboard'));
684
  }
685
 
 
686
  async function regenKey(){
687
- if(!confirm('Regenerate master key? You will be logged out.'))return;
688
- const r=await fetch('/api/settings/regen-key',{method:'POST',headers:headers()});
689
- if(r.ok){clearKey();location.reload()}
690
- }
691
-
692
- // ---------- Hint ----------
693
- function buildHint(){
694
- const origin=location.origin;
695
- document.getElementById('hint-pre').textContent=`# Route to a specific provider (prefix with providerName/)
696
- curl ${origin}/v1/chat/completions \\
697
- -H "Authorization: Bearer YOUR_MASTER_KEY" \\
698
- -H "Content-Type: application/json" \\
699
- -d '{
700
- "model": "groq/llama3-8b-8192",
701
- "messages": [{"role":"user","content":"Hello!"}]
702
- }'
703
-
704
- # Use default provider (no prefix)
705
- curl ${origin}/v1/chat/completions \\
706
- -H "Authorization: Bearer YOUR_MASTER_KEY" \\
707
- -d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hi"}]}'
708
-
709
- # List all models across all providers
710
- curl ${origin}/v1/models \\
711
- -H "Authorization: Bearer YOUR_MASTER_KEY"`;
712
- }
713
-
714
- function toggleHint(){
715
- const body=document.getElementById('hint-body');
716
- const ch=document.getElementById('hint-chevron');
717
- const open=body.classList.toggle('open');
718
- ch.textContent=open?'β–²':'β–Ό';
719
  }
720
 
721
- // ---------- Utils ----------
722
- function esc(s){if(!s)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
723
-
724
- function toggleVis(id){
725
- const el=document.getElementById(id);
726
- el.type=el.type==='password'?'text':'password';
727
  }
728
 
729
- function toast(msg){
730
- const t=document.getElementById('toast');
731
- t.textContent=msg;t.classList.add('show');
732
- setTimeout(()=>t.classList.remove('show'),2500);
733
- }
734
  </script>
735
  </body>
736
  </html>"""
@@ -738,275 +431,214 @@ function toast(msg){
738
 
739
  @app.get("/", response_class=HTMLResponse)
740
  async def dashboard():
741
- return HTMLResponse(content=DASHBOARD_HTML)
742
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
 
744
- # ---------------------------------------------------------------------------
745
- # Internal dashboard API routes
746
- # ---------------------------------------------------------------------------
747
 
748
- @app.get("/api/me")
749
- async def me(_: bool = Depends(verify_master_key)):
 
 
 
 
750
  return {"ok": True}
751
 
752
 
753
- @app.get("/api/providers")
754
- async def list_providers(_: bool = Depends(verify_master_key), db: Session = Depends(get_db)):
755
- providers = db.query(Provider).order_by(Provider.created_at).all()
756
- return [
757
- {
758
- "id": p.id,
759
- "name": p.name,
760
- "base_url": p.base_url,
761
- "key_tail": p.api_key[-4:] if p.api_key else "????",
762
- "enabled": p.enabled,
763
- "is_default": p.is_default,
764
- }
765
- for p in providers
766
- ]
767
-
768
-
769
- @app.post("/api/providers", status_code=201)
770
- async def create_provider(
771
- body: dict,
772
- _: bool = Depends(verify_master_key),
773
- db: Session = Depends(get_db),
774
- ):
775
- name = body.get("name", "").strip()
776
- base_url = body.get("base_url", "").strip().rstrip("/")
777
- api_key = body.get("api_key", "").strip()
778
- if not name or not base_url or not api_key:
779
- raise HTTPException(400, "name, base_url, and api_key are required")
780
- exists = db.query(Provider).filter(func.lower(Provider.name) == name.lower()).first()
781
- if exists:
782
- raise HTTPException(409, f"Provider '{name}' already exists")
783
- is_first = db.query(Provider).count() == 0
784
- p = Provider(name=name, base_url=base_url, api_key=api_key, is_default=is_first)
785
- db.add(p)
786
- db.commit()
787
- db.refresh(p)
788
- return {"id": p.id, "name": p.name}
789
-
790
-
791
- @app.patch("/api/providers/{provider_id}")
792
- async def update_provider(
793
- provider_id: int,
794
- body: dict,
795
- _: bool = Depends(verify_master_key),
796
- db: Session = Depends(get_db),
797
- ):
798
- p = db.query(Provider).filter(Provider.id == provider_id).first()
799
- if not p:
800
- raise HTTPException(404, "Provider not found")
801
- if "base_url" in body:
802
- p.base_url = body["base_url"].strip().rstrip("/")
803
- if "api_key" in body:
804
- p.api_key = body["api_key"].strip()
805
- if "enabled" in body:
806
- p.enabled = bool(body["enabled"])
807
- if body.get("is_default"):
808
- # Clear other defaults
809
- db.query(Provider).filter(Provider.id != provider_id).update({"is_default": False})
810
- p.is_default = True
811
- db.commit()
812
- return {"ok": True}
813
 
 
 
 
 
 
 
 
 
 
814
 
815
- @app.delete("/api/providers/{provider_id}")
816
- async def delete_provider(
817
- provider_id: int,
818
- _: bool = Depends(verify_master_key),
819
- db: Session = Depends(get_db),
820
- ):
821
- p = db.query(Provider).filter(Provider.id == provider_id).first()
822
- if not p:
823
- raise HTTPException(404, "Provider not found")
824
- db.delete(p)
825
- db.commit()
826
- return {"ok": True}
827
 
 
828
 
829
- @app.get("/api/logs")
830
- async def get_logs(_: bool = Depends(verify_master_key), db: Session = Depends(get_db)):
831
- logs = (
832
- db.query(RequestLog)
833
- .order_by(RequestLog.timestamp.desc())
834
- .limit(200)
835
- .all()
836
- )
837
- return [
838
- {
839
- "timestamp": r.timestamp.isoformat() if r.timestamp else None,
840
- "provider": r.provider,
841
- "model": r.model,
842
- "status_code": r.status_code,
843
- "latency_ms": r.latency_ms,
844
- "endpoint": r.endpoint,
845
- }
846
- for r in logs
847
- ]
848
-
849
-
850
- @app.get("/api/settings")
851
- async def get_settings(_: bool = Depends(verify_master_key), db: Session = Depends(get_db)):
852
- return {"master_key": get_master_key(db)}
853
-
854
-
855
- @app.post("/api/settings/regen-key")
856
- async def regen_key(_: bool = Depends(verify_master_key), db: Session = Depends(get_db)):
857
- new_key = "sk-proxy-" + secrets.token_urlsafe(32)
858
- row = db.query(Setting).filter(Setting.key == "master_key").first()
859
- if row:
860
- row.value = new_key
861
- else:
862
- db.add(Setting(key="master_key", value=new_key))
863
- db.commit()
864
- logger.info(f"Master key regenerated: {new_key}")
865
- return {"ok": True}
866
 
867
 
868
- # ---------------------------------------------------------------------------
869
- # Health
870
- # ---------------------------------------------------------------------------
 
 
 
 
 
 
871
 
872
- @app.get("/health")
873
- async def health(db: Session = Depends(get_db)):
874
- n = db.query(Provider).filter(Provider.enabled == True).count()
875
- return {"status": "ok", "providers": n}
876
 
 
 
 
 
 
 
 
 
 
 
 
 
 
877
 
878
- # ---------------------------------------------------------------------------
879
- # Model listing β€” aggregate from all enabled providers
880
- # ---------------------------------------------------------------------------
881
 
882
- @app.get("/v1/models")
883
- async def list_models(_: bool = Depends(verify_master_key), db: Session = Depends(get_db)):
884
- providers = db.query(Provider).filter(Provider.enabled == True).all()
 
 
 
 
 
 
 
 
 
 
 
 
 
885
  all_models = []
886
- async with httpx.AsyncClient(timeout=10) as client:
887
  for p in providers:
888
  try:
889
- r = await client.get(
890
- f"{p.base_url}/models",
891
- headers={"Authorization": f"Bearer {p.api_key}"},
892
- )
893
- if r.is_success:
894
- data = r.json()
895
- for m in data.get("data", []):
896
- m["id"] = f"{p.name}/{m['id']}"
897
  all_models.append(m)
898
- except Exception as exc:
899
- logger.warning(f"Could not fetch models from {p.name}: {exc}")
900
- return {"object": "list", "data": all_models}
901
 
902
 
903
- # ---------------------------------------------------------------------------
904
- # Core proxy β€” /v1/* catch-all
905
- # ---------------------------------------------------------------------------
906
 
907
- @app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
908
- async def proxy(path: str, request: Request, db: Session = Depends(get_db)):
909
- # Auth
910
- auth = request.headers.get("Authorization", "")
911
- if not auth.startswith("Bearer "):
912
- raise HTTPException(401, "Missing Authorization header")
913
- token = auth[len("Bearer "):]
914
- master = get_master_key(db)
915
- if not secrets.compare_digest(token, master):
916
- raise HTTPException(401, "Invalid master API key")
917
-
918
- # Parse body (if any)
919
  body_bytes = await request.body()
920
- body_json: Optional[dict] = None
921
- model_str = ""
922
- try:
923
- if body_bytes:
924
- body_json = __import__("json").loads(body_bytes)
925
- model_str = body_json.get("model", "")
926
- except Exception:
927
- pass
928
-
929
- # Route
930
- provider, real_model = parse_model(model_str, db)
931
-
932
- # Rewrite model in body
933
- downstream_body = body_bytes
934
- if body_json is not None and model_str:
 
 
 
 
 
 
 
 
 
935
  body_json["model"] = real_model
936
- downstream_body = __import__("json").dumps(body_json).encode()
937
 
938
- # Forward headers (strip host + auth)
939
- skip = {"host", "authorization", "content-length"}
940
- forward_headers = {
941
- k: v for k, v in request.headers.items() if k.lower() not in skip
 
942
  }
943
- forward_headers["Authorization"] = f"Bearer {provider.api_key}"
944
-
945
- url = f"{provider.base_url}/{path}"
946
- if request.query_params:
947
- url += "?" + str(request.query_params)
948
 
949
- is_streaming = body_json.get("stream", False) if body_json else False
950
- start = time.perf_counter()
951
- status_code = 200
952
 
953
  if is_streaming:
954
- async def stream_gen():
955
- nonlocal status_code
956
- try:
957
- async with httpx.AsyncClient(timeout=None) as client:
958
- async with client.stream(
959
- request.method,
960
- url,
961
- headers=forward_headers,
962
- content=downstream_body,
963
- ) as resp:
964
- status_code = resp.status_code
965
- async for chunk in resp.aiter_bytes():
966
- yield chunk
967
- except Exception as exc:
968
- logger.error(f"Stream error: {exc}")
969
- yield b"data: [DONE]\n\n"
970
- finally:
971
- latency = (time.perf_counter() - start) * 1000
972
- _log_async(db, provider.name, model_str or real_model, status_code, latency, f"/v1/{path}")
973
-
974
- return StreamingResponse(
975
- stream_gen(),
976
- media_type="text/event-stream",
977
- headers={
978
- "Cache-Control": "no-cache",
979
- "X-Accel-Buffering": "no",
980
- },
981
- )
982
- else:
983
- try:
984
- async with httpx.AsyncClient(timeout=60) as client:
985
- resp = await client.request(
986
- request.method,
987
- url,
988
- headers=forward_headers,
989
- content=downstream_body,
990
- )
991
- status_code = resp.status_code
992
- latency = (time.perf_counter() - start) * 1000
993
- log_request(db, provider.name, model_str or real_model, status_code, latency, f"/v1/{path}")
994
-
995
- content_type = resp.headers.get("content-type", "application/json")
996
- return JSONResponse(
997
- content=resp.json() if "json" in content_type else {"raw": resp.text},
998
- status_code=status_code,
999
- )
1000
- except httpx.TimeoutException:
1001
- raise HTTPException(504, "Upstream provider timed out")
1002
- except Exception as exc:
1003
- logger.error(f"Proxy error: {exc}")
1004
- raise HTTPException(502, f"Proxy error: {exc}")
1005
-
1006
-
1007
- def _log_async(db: Session, *args):
1008
- """Fire-and-forget log β€” called from streaming context after response."""
1009
  try:
1010
- log_request(db, *args)
1011
- except Exception as exc:
1012
- logger.warning(f"Log write failed: {exc}")
 
 
 
 
 
 
 
1
  import os
2
+ import re
3
+ import json
4
  import time
5
+ import secrets
6
  from contextlib import asynccontextmanager
7
  from datetime import datetime
8
+ from typing import AsyncGenerator
9
 
10
  import httpx
11
+ from fastapi import FastAPI, Depends, HTTPException, Request
12
  from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
14
+ from pydantic import BaseModel
15
  from sqlalchemy import (
16
+ Column, String, Boolean, Integer, DateTime, Float, Text,
17
+ create_engine, select, update, delete
18
  )
19
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
20
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
21
 
22
+ # ── Database ──────────────────────────────────────────────────────────────────
 
23
 
24
+ if os.path.isdir("/data"):
25
+ ASYNC_DB = "sqlite+aiosqlite:////data/db.sqlite"
26
+ SYNC_DB = "sqlite:////data/db.sqlite"
27
+ else:
28
+ ASYNC_DB = "sqlite+aiosqlite:///./db.sqlite"
29
+ SYNC_DB = "sqlite:///./db.sqlite"
30
 
 
 
31
 
32
+ class Base(DeclarativeBase):
33
+ pass
 
34
 
35
 
36
  class Provider(Base):
37
  __tablename__ = "providers"
38
+ id = Column(Integer, primary_key=True, autoincrement=True)
39
+ name = Column(String(100), unique=True, nullable=False)
40
+ base_url = Column(String(500), nullable=False)
41
+ api_key = Column(String(500), nullable=False)
42
+ enabled = Column(Boolean, default=True)
43
  is_default = Column(Boolean, default=False)
44
  created_at = Column(DateTime, default=datetime.utcnow)
45
 
46
 
 
 
 
 
 
 
47
  class RequestLog(Base):
48
  __tablename__ = "request_logs"
49
+ id = Column(Integer, primary_key=True, autoincrement=True)
50
+ timestamp = Column(DateTime, default=datetime.utcnow)
51
+ provider_name = Column(String(100))
52
+ model = Column(String(200))
53
+ status_code = Column(Integer)
54
+ latency_ms = Column(Float)
55
+ path = Column(String(200))
56
+ streaming = Column(Boolean, default=False)
57
 
 
58
 
59
+ class Settings(Base):
60
+ __tablename__ = "settings"
61
+ key = Column(String(100), primary_key=True)
62
+ value = Column(Text, nullable=False)
63
+
64
+
65
+ def init_db():
66
+ engine = create_engine(SYNC_DB, connect_args={"check_same_thread": False})
67
+ Base.metadata.create_all(engine)
68
+ Session = sessionmaker(bind=engine)
69
+ with Session() as s:
70
+ existing = s.get(Settings, "master_key")
71
+ if not existing:
72
+ key = f"umk-{secrets.token_urlsafe(32)}"
73
+ s.add(Settings(key="master_key", value=key))
74
+ s.commit()
75
+ print(f"\n{'='*60}\n MASTER API KEY (save this):\n {key}\n{'='*60}\n")
76
+ else:
77
+ print(f"[startup] Master key ends: ...{existing.value[-4:]}")
78
+ # Auto-seed from env: PROVIDER_<NAME>_URL + PROVIDER_<NAME>_KEY
79
+ for env_k, env_v in os.environ.items():
80
+ m = re.match(r"^PROVIDER_([A-Z0-9_]+)_URL$", env_k)
81
+ if m:
82
+ pname = m.group(1).capitalize()
83
+ key_env = f"PROVIDER_{m.group(1)}_KEY"
84
+ pkey = os.environ.get(key_env, "")
85
+ if pkey and not s.query(Provider).filter_by(name=pname).first():
86
+ s.add(Provider(name=pname, base_url=env_v, api_key=pkey))
87
+ print(f"[startup] Seeded provider: {pname}")
88
+ s.commit()
89
+
90
+
91
+ # ── Lifespan ──────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  @asynccontextmanager
94
+ async def lifespan(app: FastAPI):
95
+ init_db()
96
+ engine = create_async_engine(ASYNC_DB)
97
+ app.state.db = async_sessionmaker(engine, expire_on_commit=False)
 
 
 
98
  yield
99
+ await engine.dispose()
100
 
101
 
 
 
 
 
102
  app = FastAPI(title="Unified AI Proxy", lifespan=lifespan)
103
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
104
 
105
+ TIMEOUT = httpx.Timeout(120.0, connect=10.0)
106
+
107
+ # ── Auth helper ───────────────────────────────────────────────────────────────
108
+
109
+ async def verify_key(request: Request):
110
+ auth = request.headers.get("Authorization", "")
111
+ if not auth.startswith("Bearer "):
112
+ raise HTTPException(401, "Missing Authorization header")
113
+ token = auth.removeprefix("Bearer ").strip()
114
+ async with request.app.state.db() as s:
115
+ row = await s.get(Settings, "master_key")
116
+ if not row or row.value != token:
117
+ raise HTTPException(401, "Invalid master key")
118
+
119
+ # ── Health ────────────────────────────────────────────────────────────────────
120
 
121
+ @app.get("/health")
122
+ async def health(request: Request):
123
+ async with request.app.state.db() as s:
124
+ result = await s.execute(select(Provider).where(Provider.enabled == True))
125
+ n = len(result.scalars().all())
126
+ return {"status": "ok", "providers": n}
127
 
128
+ # ── Dashboard ─────────────────────────────────────────────────────────────────
 
 
129
 
130
  DASHBOARD_HTML = r"""<!DOCTYPE html>
131
  <html lang="en">
132
  <head>
133
  <meta charset="UTF-8"/>
134
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
135
  <title>Unified AI Proxy</title>
136
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
137
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
138
  <style>
139
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
140
+ :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}
141
+ body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;min-height:100vh}
142
+ #login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}
143
+ .login-box{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:36px 32px;width:100%;max-width:400px}
144
+ .login-box h1{font-size:20px;font-weight:600;margin-bottom:6px}
145
+ .login-box p{color:var(--text-dim);margin-bottom:24px;font-size:13px}
146
+ .err{color:var(--red);font-size:13px;margin-top:10px;display:none}
147
+ #app{display:none;flex-direction:column;min-height:100vh}
148
+ 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}
149
+ .logo{font-family:var(--mono);font-size:15px;font-weight:500;color:var(--accent)}
150
+ .logo span{color:var(--text-dim)}
151
+ nav{display:flex;gap:2px;margin-left:8px}
152
+ 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}
153
+ nav button:hover{background:var(--surface2);color:var(--text)}
154
+ nav button.active{background:var(--accent-dim);color:var(--accent)}
155
+ .spacer{flex:1}
156
+ .hstatus{font-size:12px;color:var(--text-dim);font-family:var(--mono)}
157
+ main{flex:1;padding:24px;max-width:1100px;margin:0 auto;width:100%}
158
+ .tab{display:none}.tab.active{display:block}
159
+ .sh{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
160
+ .sh h2{font-size:16px;font-weight:600}
161
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:20px;margin-bottom:16px}
162
+ .card-title{font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;margin-bottom:16px}
163
+ .form-row{display:grid;gap:12px;margin-bottom:12px}
164
+ .cols-3{grid-template-columns:1fr 1fr 1fr}
165
+ .cols-2{grid-template-columns:1fr 1fr}
166
+ label{display:block;font-size:12px;color:var(--text-dim);margin-bottom:5px;font-weight:500}
167
+ 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}
168
+ input:focus{border-color:var(--accent)}
169
+ .iw{position:relative}.iw input{padding-right:40px}
170
+ .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}
171
+ .tv:hover{color:var(--text)}
172
+ .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}
173
+ .bp{background:var(--accent);color:#000;border-color:var(--accent)}.bp:hover{background:#79b8ff}
174
+ .bs{background:var(--surface2);color:var(--text);border-color:var(--border)}.bs:hover{border-color:var(--accent);color:var(--accent)}
175
+ .bd{background:transparent;color:var(--red);border-color:var(--border)}.bd:hover{background:rgba(248,81,73,.1);border-color:var(--red)}
176
+ .sm{padding:4px 10px;font-size:12px}
177
+ .tw{overflow-x:auto}
178
+ table{width:100%;border-collapse:collapse}
179
+ 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)}
180
+ td{padding:11px 12px;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle}
181
+ tr:last-child td{border-bottom:none}
182
+ tr:hover td{background:var(--surface2)}
183
+ .mono{font-family:var(--mono);font-size:12px}
184
+ .pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500}
185
+ .pg{background:rgba(63,185,80,.15);color:var(--green)}
186
+ .pr{background:rgba(248,81,73,.15);color:var(--red)}
187
+ .pb{background:var(--accent-dim);color:var(--accent)}
188
+ .py{background:rgba(210,153,34,.15);color:var(--yellow)}
189
+ .toggle{position:relative;display:inline-block;width:36px;height:20px}
190
+ .toggle input{opacity:0;width:0;height:0}
191
+ .slider{position:absolute;inset:0;background:var(--border);border-radius:20px;cursor:pointer;transition:.2s}
192
+ .slider::before{content:'';position:absolute;width:14px;height:14px;left:3px;top:3px;background:var(--text-dim);border-radius:50%;transition:.2s}
193
+ input:checked+.slider{background:var(--accent)}
194
+ input:checked+.slider::before{transform:translateX(16px);background:#000}
195
+ .ub{background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-bottom:16px;overflow:hidden}
196
+ .ubh{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;cursor:pointer;user-select:none}
197
+ .ubh span{font-size:12px;color:var(--text-dim);font-weight:500}
198
+ .chev{color:var(--text-dim);transition:transform .2s}
199
+ .ubh.open .chev{transform:rotate(180deg)}
200
+ .ubc{display:none;padding:0 16px 16px}
201
+ .ubc.open{display:block}
202
+ 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}
203
+ .empty{text-align:center;padding:40px;color:var(--text-dim);font-size:13px}
204
+ #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}
205
+ #flash.show{opacity:1;transform:translateY(0)}
206
+ #flash.ok{border-left:3px solid var(--green)}
207
+ #flash.err{border-left:3px solid var(--red)}
208
+ @media(max-width:680px){.cols-3,.cols-2{grid-template-columns:1fr}.hstatus{display:none}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  </style>
210
  </head>
211
  <body>
212
+ <div id="login-screen">
213
+ <div class="login-box">
214
+ <h1>πŸ”€ Unified AI Proxy</h1>
215
+ <p>Enter your master API key to continue.</p>
216
+ <div><label>Master API Key</label>
217
+ <div class="iw"><input type="password" id="lk" placeholder="umk-..."/><button class="tv" onclick="tv('lk',this)">show</button></div>
 
 
 
 
 
 
 
 
 
218
  </div>
219
+ <button class="btn bp" style="margin-top:14px;width:100%" onclick="doLogin()">Sign In</button>
220
+ <div class="err" id="lerr">Invalid key β€” check Space logs on first launch.</div>
221
  </div>
222
  </div>
223
 
224
+ <div id="app">
225
+ <header>
226
+ <div class="logo">πŸ”€ <span>unified-ai-proxy</span></div>
227
+ <nav>
228
+ <button class="active" onclick="showTab('providers',this)">Providers</button>
229
+ <button onclick="showTab('logs',this)">Logs</button>
230
+ <button onclick="showTab('settings',this)">Settings</button>
231
+ </nav>
232
  <div class="spacer"></div>
233
+ <div class="hstatus" id="hs">● connecting…</div>
234
+ </header>
235
+ <main>
 
 
 
 
 
 
 
 
 
236
 
237
  <!-- PROVIDERS -->
238
+ <div class="tab active" id="tab-providers">
239
+ <div class="ub">
240
+ <div class="ubh" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
241
+ <span>πŸ“– How to use the proxy</span><span class="chev">β–Ύ</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  </div>
243
+ <div class="ubc">
244
+ <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>
245
+ <pre>curl https://YOUR-SPACE.hf.space/v1/chat/completions \
246
+ -H "Authorization: Bearer YOUR_MASTER_KEY" \
247
+ -H "Content-Type: application/json" \
248
+ -d '{"model":"groq/llama3-8b-8192","messages":[{"role":"user","content":"Hello!"}]}'</pre>
 
 
 
 
 
 
249
  </div>
250
  </div>
 
251
  <div class="card">
252
+ <div class="card-title">Add Provider</div>
253
+ <div class="form-row cols-3">
254
+ <div><label>Name</label><input type="text" id="an" placeholder="Groq"/></div>
255
+ <div><label>Base URL</label><input type="text" id="au" placeholder="https://api.groq.com/openai/v1"/></div>
256
+ <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>
 
 
 
 
 
257
  </div>
258
+ <button class="btn bp" onclick="addProvider()">+ Add Provider</button>
259
+ </div>
260
+ <div class="card" style="padding:0">
261
+ <div class="tw"><table>
262
+ <thead><tr><th>Name</th><th>Base URL</th><th>Key</th><th>Default</th><th>Enabled</th><th>Actions</th></tr></thead>
263
+ <tbody id="ptb"><tr><td colspan="6" class="empty">Loading…</td></tr></tbody>
264
+ </table></div>
265
  </div>
266
  </div>
267
 
268
  <!-- LOGS -->
269
+ <div class="tab" id="tab-logs">
270
+ <div class="sh"><h2>Request Logs</h2><span style="font-size:12px;color:var(--text-dim)">Auto-refreshes every 5s Β· last 200</span></div>
271
+ <div class="card" style="padding:0">
272
+ <div class="tw"><table>
273
+ <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>
274
+ <tbody id="ltb"><tr><td colspan="7" class="empty">No requests yet.</td></tr></tbody>
275
+ </table></div>
 
 
 
 
 
 
 
 
 
 
 
276
  </div>
277
  </div>
278
 
279
  <!-- SETTINGS -->
280
+ <div class="tab" id="tab-settings">
281
  <div class="card">
282
+ <div class="card-title">Master API Key</div>
283
+ <p style="color:var(--text-dim);font-size:13px;margin-bottom:14px">Authenticates all proxy requests and dashboard access.</p>
284
+ <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
285
+ <code class="mono" id="kd" style="background:var(--bg);border:1px solid var(--border);padding:8px 14px;border-radius:6px">loading…</code>
286
+ <button class="btn bs sm" onclick="copyKey()">Copy Key</button>
287
+ <button class="btn bd sm" onclick="regenKey()">Regenerate</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  </div>
289
+ <p style="color:var(--text-dim);font-size:12px;margin-top:10px">⚠ Regenerating invalidates the current key immediately.</p>
290
+ </div>
291
+ <div class="card">
292
+ <div class="card-title">Default Provider</div>
293
+ <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>
294
+ <div id="defp" style="font-family:var(--mono);color:var(--accent);font-size:13px">loading…</div>
295
+ </div>
296
+ <div class="card">
297
+ <div class="card-title">Base Endpoint</div>
298
+ <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>
299
  </div>
300
  </div>
301
 
302
+ </main>
303
  </div>
304
+ <div id="flash"></div>
 
 
305
  <script>
306
+ let masterKey = sessionStorage.getItem('mk') || '';
307
+ let fullKey = '';
308
+
309
+ async function apiFetch(path, {key, method='GET', body}={}) {
310
+ const k = key||masterKey;
311
+ const opts = {method, headers:{'Authorization':`Bearer ${k}`,'Content-Type':'application/json'}};
312
+ if(body) opts.body = JSON.stringify(body);
313
+ return fetch(path, opts);
 
 
 
 
 
 
 
 
 
 
 
314
  }
315
 
316
+ async function doLogin() {
317
+ const k = document.getElementById('lk').value.trim();
318
+ if(!k) return;
319
+ const r = await apiFetch('/api/providers',{key:k}).catch(()=>null);
320
+ if(r&&r.ok) { masterKey=k; sessionStorage.setItem('mk',k); showApp(); }
321
+ else document.getElementById('lerr').style.display='block';
322
  }
323
+ document.getElementById('lk').addEventListener('keydown',e=>{ if(e.key==='Enter') doLogin(); });
324
 
325
+ function showApp() {
 
 
326
  document.getElementById('login-screen').style.display='none';
327
+ document.getElementById('app').style.display='flex';
328
+ document.getElementById('burl').textContent = location.origin+'/v1';
329
+ loadAll(); setInterval(loadLogs,5000); setInterval(updateStatus,10000); updateStatus();
330
  }
331
 
332
+ if(masterKey) apiFetch('/api/providers').then(r=>{ if(r.ok) showApp(); else { masterKey=''; sessionStorage.removeItem('mk'); } }).catch(()=>{});
 
 
 
333
 
334
+ function flash(msg,type='ok') {
335
+ const el=document.getElementById('flash');
336
+ el.textContent=msg; el.className=`show ${type}`;
337
+ clearTimeout(el._t); el._t=setTimeout(()=>el.className='',3000);
 
 
338
  }
339
 
340
+ function showTab(name,btn) {
 
 
341
  document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
342
+ document.querySelectorAll('nav button').forEach(b=>b.classList.remove('active'));
343
+ document.getElementById(`tab-${name}`).classList.add('active');
344
+ btn.classList.add('active');
345
+ if(name==='logs') loadLogs();
346
+ if(name==='settings') loadSettings();
 
 
 
 
 
 
 
 
 
 
347
  }
348
 
349
+ async function loadProviders() {
350
+ const r = await apiFetch('/api/providers');
351
+ if(!r.ok) return;
352
+ const list = await r.json();
353
+ const tb = document.getElementById('ptb');
354
+ if(!list.length) { tb.innerHTML='<tr><td colspan="6" class="empty">No providers yet.</td></tr>'; return; }
355
+ tb.innerHTML = list.map(p=>`<tr>
356
+ <td><strong>${e(p.name)}</strong>${p.is_default?' <span class="pill pb">default</span>':''}</td>
357
+ <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>
358
+ <td class="mono" style="color:var(--text-dim)">${e(p.key_preview)}</td>
359
+ <td><label class="toggle"><input type="checkbox" ${p.is_default?'checked':''} onchange="setDefault(${p.id},this.checked)"/><span class="slider"></span></label></td>
360
+ <td><label class="toggle"><input type="checkbox" ${p.enabled?'checked':''} onchange="setEnabled(${p.id},this.checked)"/><span class="slider"></span></label></td>
361
+ <td style="display:flex;gap:6px;flex-wrap:wrap">
362
+ <button class="btn bs sm" onclick="editUrl(${p.id},'${e(p.base_url)}')">Edit URL</button>
363
+ <button class="btn bs sm" onclick="editKey(${p.id})">Edit Key</button>
364
+ <button class="btn bd sm" onclick="delProvider(${p.id},'${e(p.name)}')">Delete</button>
365
+ </td></tr>`).join('');
366
  }
367
 
368
+ async function addProvider() {
369
+ const name=document.getElementById('an').value.trim();
370
+ const url=document.getElementById('au').value.trim();
371
+ const key=document.getElementById('ak').value.trim();
372
+ if(!name||!url||!key){flash('All fields required','err');return;}
373
+ const r=await apiFetch('/api/providers',{method:'POST',body:{name,base_url:url,api_key:key}});
374
+ if(r.ok){flash(`Provider "${name}" added`);document.getElementById('an').value='';document.getElementById('au').value='';document.getElementById('ak').value='';loadProviders();}
375
+ else{const d=await r.json().catch(()=>({}));flash(d.detail||'Failed','err');}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  }
377
 
378
+ async function delProvider(id,name){
379
+ if(!confirm(`Delete "${name}"?`)) return;
380
+ const r=await apiFetch(`/api/providers/${id}`,{method:'DELETE'});
381
+ if(r.ok){flash(`Deleted "${name}"`);loadProviders();}else flash('Delete failed','err');
 
 
 
 
 
 
 
 
 
 
382
  }
383
+ async function setEnabled(id,enabled){await apiFetch(`/api/providers/${id}`,{method:'PATCH',body:{enabled}});loadProviders();}
384
+ async function setDefault(id,is_default){await apiFetch(`/api/providers/${id}`,{method:'PATCH',body:{is_default}});loadProviders();loadSettings();}
385
+ 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');}
386
+ 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');}
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  async function loadLogs(){
389
+ const r=await apiFetch('/api/logs');if(!r.ok)return;
 
390
  const logs=await r.json();
391
+ const tb=document.getElementById('ltb');
392
+ if(!logs.length){tb.innerHTML='<tr><td colspan="7" class="empty">No requests yet.</td></tr>';return;}
393
  tb.innerHTML=logs.map(l=>{
394
+ const ok=l.status_code&&l.status_code<400;
395
+ const ts=l.timestamp?new Date(l.timestamp+'Z').toLocaleTimeString():'β€”';
396
+ 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>`;
 
 
 
 
 
 
 
 
397
  }).join('');
398
  }
399
 
 
400
  async function loadSettings(){
401
+ const r=await apiFetch('/api/settings/key');if(!r.ok)return;
402
+ const d=await r.json();fullKey=d.full_key;
403
+ document.getElementById('kd').textContent=d.key_preview;
404
+ const pr=await apiFetch('/api/providers');
405
+ if(pr.ok){const ps=await pr.json();const def=ps.find(p=>p.is_default);
406
+ document.getElementById('defp').textContent=def?`${def.name} β†’ ${def.base_url}`:'No default set.';}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  }
408
 
409
+ function copyKey(){if(!fullKey)return;navigator.clipboard.writeText(fullKey).then(()=>flash('Key copied'));}
410
  async function regenKey(){
411
+ if(!confirm('Regenerate master key? All clients will break immediately.')) return;
412
+ const r=await apiFetch('/api/settings/key/regenerate',{method:'POST'});
413
+ if(r.ok){const d=await r.json();fullKey=d.key;masterKey=d.key;sessionStorage.setItem('mk',d.key);
414
+ document.getElementById('kd').textContent=`...${d.key.slice(-8)}`;flash('Key regenerated β€” copy it now!');}
415
+ else flash('Failed','err');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  }
417
 
418
+ async function updateStatus(){
419
+ try{const r=await fetch('/health');if(r.ok){const d=await r.json();
420
+ document.getElementById('hs').innerHTML=`<span style="color:var(--green)">●</span> ${d.providers} provider${d.providers!==1?'s':''}`;}}
421
+ catch{document.getElementById('hs').innerHTML='<span style="color:var(--red)">●</span> offline';}
 
 
422
  }
423
 
424
+ function e(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
425
+ 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';}}
426
+ function loadAll(){loadProviders();loadLogs();loadSettings();}
 
 
427
  </script>
428
  </body>
429
  </html>"""
 
431
 
432
  @app.get("/", response_class=HTMLResponse)
433
  async def dashboard():
434
+ return DASHBOARD_HTML
435
+
436
+
437
+ # ── Provider CRUD ─────────────────────────────────────────────────────────────
438
+
439
+ class ProviderCreate(BaseModel):
440
+ name: str; base_url: str; api_key: str
441
+
442
+ class ProviderPatch(BaseModel):
443
+ base_url: str | None = None
444
+ api_key: str | None = None
445
+ enabled: bool | None = None
446
+ is_default: bool | None = None
447
+
448
+
449
+ @app.get("/api/providers", dependencies=[Depends(verify_key)])
450
+ async def get_providers(request: Request):
451
+ async with request.app.state.db() as s:
452
+ res = await s.execute(select(Provider).order_by(Provider.id))
453
+ ps = res.scalars().all()
454
+ return [{"id":p.id,"name":p.name,"base_url":p.base_url,
455
+ "key_preview":f"...{p.api_key[-4:]}" if len(p.api_key)>=4 else "****",
456
+ "enabled":p.enabled,"is_default":p.is_default} for p in ps]
457
+
458
+
459
+ @app.post("/api/providers", dependencies=[Depends(verify_key)])
460
+ async def create_provider(body: ProviderCreate, request: Request):
461
+ async with request.app.state.db() as s:
462
+ ex = await s.execute(select(Provider).where(Provider.name == body.name))
463
+ if ex.scalar(): raise HTTPException(400, f"Provider '{body.name}' already exists")
464
+ p = Provider(name=body.name, base_url=body.base_url, api_key=body.api_key)
465
+ s.add(p); await s.commit(); await s.refresh(p)
466
+ return {"id": p.id, "name": p.name}
467
+
468
+
469
+ @app.patch("/api/providers/{pid}", dependencies=[Depends(verify_key)])
470
+ async def patch_provider(pid: int, body: ProviderPatch, request: Request):
471
+ async with request.app.state.db() as s:
472
+ p = await s.get(Provider, pid)
473
+ if not p: raise HTTPException(404, "Not found")
474
+ if body.base_url is not None: p.base_url = body.base_url
475
+ if body.api_key is not None: p.api_key = body.api_key
476
+ if body.enabled is not None: p.enabled = body.enabled
477
+ if body.is_default is not None:
478
+ if body.is_default:
479
+ await s.execute(update(Provider).where(Provider.id != pid).values(is_default=False))
480
+ p.is_default = body.is_default
481
+ await s.commit()
482
+ return {"ok": True}
483
 
 
 
 
484
 
485
+ @app.delete("/api/providers/{pid}", dependencies=[Depends(verify_key)])
486
+ async def delete_provider(pid: int, request: Request):
487
+ async with request.app.state.db() as s:
488
+ p = await s.get(Provider, pid)
489
+ if not p: raise HTTPException(404, "Not found")
490
+ await s.delete(p); await s.commit()
491
  return {"ok": True}
492
 
493
 
494
+ # ── Logs ──────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
 
496
+ @app.get("/api/logs", dependencies=[Depends(verify_key)])
497
+ async def get_logs(request: Request):
498
+ async with request.app.state.db() as s:
499
+ res = await s.execute(select(RequestLog).order_by(RequestLog.id.desc()).limit(200))
500
+ logs = res.scalars().all()
501
+ return [{"id":l.id,"timestamp":l.timestamp.isoformat() if l.timestamp else None,
502
+ "provider_name":l.provider_name,"model":l.model,"status_code":l.status_code,
503
+ "latency_ms":round(l.latency_ms,1) if l.latency_ms else None,
504
+ "path":l.path,"streaming":l.streaming} for l in logs]
505
 
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
+ # ── Settings ──────────────────────────────────────────────────────────────────
508
 
509
+ @app.get("/api/settings/key", dependencies=[Depends(verify_key)])
510
+ async def get_key(request: Request):
511
+ async with request.app.state.db() as s:
512
+ row = await s.get(Settings, "master_key")
513
+ k = row.value if row else ""
514
+ return {"key_preview": f"...{k[-8:]}" if len(k)>=8 else "****", "full_key": k}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
516
 
517
+ @app.post("/api/settings/key/regenerate", dependencies=[Depends(verify_key)])
518
+ async def regen_key(request: Request):
519
+ new_key = f"umk-{secrets.token_urlsafe(32)}"
520
+ async with request.app.state.db() as s:
521
+ row = await s.get(Settings, "master_key")
522
+ if row: row.value = new_key
523
+ else: s.add(Settings(key="master_key", value=new_key))
524
+ await s.commit()
525
+ return {"key": new_key}
526
 
 
 
 
 
527
 
528
+ # ── Proxy helpers ─────────────────────────────────────────────────────────────
529
+
530
+ async def _log(session, provider_name, model, status_code, latency_ms, path, streaming):
531
+ session.add(RequestLog(timestamp=datetime.utcnow(), provider_name=provider_name,
532
+ model=model, status_code=status_code, latency_ms=latency_ms,
533
+ path=path, streaming=streaming))
534
+ await session.commit()
535
+ # Prune to 500 rows
536
+ res = await session.execute(select(RequestLog.id).order_by(RequestLog.id.desc()).offset(500))
537
+ old = res.scalars().all()
538
+ if old:
539
+ await session.execute(delete(RequestLog).where(RequestLog.id.in_(old)))
540
+ await session.commit()
541
 
 
 
 
542
 
543
+ def _parse_provider(model: str, providers):
544
+ if "/" in model:
545
+ prefix, real = model.split("/", 1)
546
+ for p in providers:
547
+ if p.name.lower() == prefix.lower():
548
+ return p, real
549
+ return None, model
550
+
551
+
552
+ # ── Models aggregation ────────────────────────────────────────────────────────
553
+
554
+ @app.get("/v1/models", dependencies=[Depends(verify_key)])
555
+ async def list_models(request: Request):
556
+ async with request.app.state.db() as s:
557
+ res = await s.execute(select(Provider).where(Provider.enabled == True))
558
+ providers = res.scalars().all()
559
  all_models = []
560
+ async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
561
  for p in providers:
562
  try:
563
+ r = await client.get(f"{p.base_url.rstrip('/')}/models",
564
+ headers={"Authorization": f"Bearer {p.api_key}"})
565
+ if r.status_code == 200:
566
+ for m in r.json().get("data", []):
567
+ m["id"] = f"{p.name.lower()}/{m['id']}"
 
 
 
568
  all_models.append(m)
569
+ except Exception:
570
+ pass
571
+ return JSONResponse({"object": "list", "data": all_models})
572
 
573
 
574
+ # ── Generic proxy ─────────────────────────────────────────────────────────────
 
 
575
 
576
+ @app.api_route("/v1/{path:path}", methods=["GET","POST","PUT","DELETE","PATCH"],
577
+ dependencies=[Depends(verify_key)])
578
+ async def proxy(request: Request, path: str):
 
 
 
 
 
 
 
 
 
579
  body_bytes = await request.body()
580
+ body_json = None
581
+ model_raw = ""
582
+ if body_bytes:
583
+ try:
584
+ body_json = json.loads(body_bytes)
585
+ model_raw = body_json.get("model", "")
586
+ except Exception:
587
+ pass
588
+
589
+ async with request.app.state.db() as s:
590
+ res = await s.execute(select(Provider).where(Provider.enabled == True))
591
+ providers = res.scalars().all()
592
+ if not providers:
593
+ raise HTTPException(503, "No providers configured")
594
+
595
+ provider, real_model = _parse_provider(model_raw, providers)
596
+ if provider is None:
597
+ for p in providers:
598
+ if p.is_default: provider = p; break
599
+ if provider is None:
600
+ provider = providers[0]
601
+
602
+ # Rewrite model name in body
603
+ if body_json is not None and model_raw:
604
  body_json["model"] = real_model
605
+ body_bytes = json.dumps(body_json).encode()
606
 
607
+ is_streaming = bool(body_json and body_json.get("stream"))
608
+ target_url = f"{provider.base_url.rstrip('/')}/{path.lstrip('/')}"
609
+ headers = {
610
+ "Authorization": f"Bearer {provider.api_key}",
611
+ "Content-Type": request.headers.get("Content-Type", "application/json"),
612
  }
 
 
 
 
 
613
 
614
+ start = time.time()
 
 
615
 
616
  if is_streaming:
617
+ logged = False
618
+ async def generate() -> AsyncGenerator[bytes, None]:
619
+ nonlocal logged
620
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
621
+ async with client.stream(request.method, target_url,
622
+ headers=headers, content=body_bytes) as resp:
623
+ latency = (time.time() - start) * 1000
624
+ if not logged:
625
+ logged = True
626
+ async with request.app.state.db() as ls:
627
+ await _log(ls, provider.name, real_model,
628
+ resp.status_code, latency, path, True)
629
+ async for chunk in resp.aiter_bytes():
630
+ yield chunk
631
+ return StreamingResponse(generate(), media_type="text/event-stream")
632
+
633
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
634
+ resp = await client.request(request.method, target_url,
635
+ headers=headers, content=body_bytes)
636
+ latency = (time.time() - start) * 1000
637
+ async with request.app.state.db() as s:
638
+ await _log(s, provider.name, real_model, resp.status_code, latency, path, False)
639
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  try:
641
+ return JSONResponse(content=resp.json(), status_code=resp.status_code)
642
+ except Exception:
643
+ return Response(content=resp.content, status_code=resp.status_code,
644
+ media_type=resp.headers.get("content-type","application/octet-stream"))