CJHauser commited on
Commit
4fe9738
Β·
1 Parent(s): 1ecdec2

Fix ASGI module import error and volume mount paths

Browse files
Dockerfile CHANGED
@@ -1,19 +1,18 @@
1
  FROM python:3.11-slim
2
 
3
-
4
 
5
  # Install dependencies first (layer cache)
6
  COPY requirements.txt .
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
9
  # Copy application code
10
- COPY app.py .
11
 
12
  # Create persistent data directory
13
- RUN mkdir -p /data
14
- VOLUME ["/app/data"]
15
 
16
  # Hugging Face Spaces requires port 7860
17
  EXPOSE 7860
18
 
19
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
  FROM python:3.11-slim
2
 
3
+ WORKDIR /app
4
 
5
  # Install dependencies first (layer cache)
6
  COPY requirements.txt .
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
9
  # Copy application code
10
+ COPY main.py .
11
 
12
  # Create persistent data directory
13
+ RUN mkdir -p /app/data
 
14
 
15
  # Hugging Face Spaces requires port 7860
16
  EXPOSE 7860
17
 
18
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
__pycache__/app.cpython-313.pyc ADDED
Binary file (46.2 kB). View file
 
__pycache__/main.cpython-313.pyc ADDED
Binary file (46.4 kB). View file
 
app/main.py DELETED
@@ -1,1012 +0,0 @@
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>"""
737
-
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}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py β†’ main.py RENAMED
@@ -21,7 +21,10 @@ 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:
 
21
 
22
  # ── Database ──────────────────────────────────────────────────────────────────
23
 
24
+ if os.path.isdir("/app/data"):
25
+ ASYNC_DB = "sqlite+aiosqlite:////app/data/db.sqlite"
26
+ SYNC_DB = "sqlite:////app/data/db.sqlite"
27
+ elif os.path.isdir("/data"):
28
  ASYNC_DB = "sqlite+aiosqlite:////data/db.sqlite"
29
  SYNC_DB = "sqlite:////data/db.sqlite"
30
  else:
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  fastapi==0.111.0
2
  uvicorn[standard]==0.29.0
3
  httpx==0.27.0
4
- sqlalchemy==2.0.30
 
 
1
  fastapi==0.111.0
2
  uvicorn[standard]==0.29.0
3
  httpx==0.27.0
4
+ sqlalchemy==2.0.30
5
+ aiosqlite==0.20.0