| """ |
| RyzGateway - BYOK OpenAI-Compatible AI Gateway |
| Single-file FastAPI app for HuggingFace Spaces deployment. |
| |
| Model routing: |
| - provider/model β direct passthrough to that provider |
| - generic-name β routed pool (round-robin, skip rate-limited) |
| |
| Rate limit handling: |
| - Auto-detects 429s and marks endpoints as cooled down |
| - Respects RPM / RPD / RPS limits configured per provider |
| - Smart selection skips exhausted endpoints |
| |
| Features: |
| - Glassmorphism UI (highly optimized for mobile touch) |
| - Auto-fetches and imports models directly from provider's /v1/models |
| """ |
|
|
| import os |
| import re |
| import time |
| import uuid |
| import json |
| import asyncio |
| import logging |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Literal |
| from contextlib import asynccontextmanager |
| from collections import defaultdict, deque |
|
|
| import httpx |
| from fastapi import FastAPI, HTTPException, Header, Request, Depends |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse |
| from pydantic import BaseModel, Field |
|
|
| |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") |
| log = logging.getLogger("ryzgateway") |
|
|
| |
|
|
| DATA_FILE = Path(os.getenv("DATA_FILE", "/data/gateway.json")) |
|
|
| def _default_data() -> dict: |
| return { |
| "api_keys": {}, |
| "providers": {}, |
| "pool_models": {}, |
| } |
|
|
| def load_data() -> dict: |
| try: |
| if DATA_FILE.exists(): |
| return json.loads(DATA_FILE.read_text()) |
| except Exception as e: |
| log.error(f"Failed to load data: {e}") |
| return _default_data() |
|
|
| def save_data(data: dict): |
| try: |
| DATA_FILE.parent.mkdir(parents=True, exist_ok=True) |
| DATA_FILE.write_text(json.dumps(data, indent=2)) |
| except Exception as e: |
| log.error(f"Failed to save data: {e}") |
|
|
| |
| |
|
|
| class RateLimitState: |
| def __init__(self): |
| self._lock = asyncio.Lock() |
| self._cooldown: Dict[str, float] = {} |
| self._timestamps: Dict[str, deque] = defaultdict(lambda: deque(maxlen=10000)) |
| self._rr_index: Dict[str, int] = defaultdict(int) |
|
|
| def key(self, provider_id: str, model_name: str) -> str: |
| return f"{provider_id}:{model_name}" |
|
|
| async def mark_rate_limited(self, provider_id: str, model_name: str, cooldown_secs: float = 60.0): |
| async with self._lock: |
| k = self.key(provider_id, model_name) |
| self._cooldown[k] = time.time() + cooldown_secs |
| log.warning(f"Rate limited: {k} β cooling for {cooldown_secs}s") |
|
|
| async def is_available(self, provider_id: str, model_name: str, provider_cfg: dict) -> bool: |
| async with self._lock: |
| k = self.key(provider_id, model_name) |
| now = time.time() |
|
|
| if self._cooldown.get(k, 0) > now: |
| return False |
|
|
| rate_type = provider_cfg.get("rate_type", "none") |
| rate_limit = provider_cfg.get("rate_limit", 0) |
| if rate_type == "none" or not rate_limit: |
| return True |
|
|
| window = {"rpm": 60, "rps": 1, "rpd": 86400}.get(rate_type, 60) |
| cutoff = now - window |
| dq = self._timestamps[k] |
| while dq and dq[0] < cutoff: |
| dq.popleft() |
| return len(dq) < rate_limit |
|
|
| async def record_request(self, provider_id: str, model_name: str): |
| async with self._lock: |
| k = self.key(provider_id, model_name) |
| self._timestamps[k].append(time.time()) |
|
|
| def get_cooldown_remaining(self, provider_id: str, model_name: str) -> float: |
| k = self.key(provider_id, model_name) |
| remaining = self._cooldown.get(k, 0) - time.time() |
| return max(0.0, remaining) |
|
|
| async def next_rr(self, pool_id: str, count: int) -> int: |
| async with self._lock: |
| idx = self._rr_index[pool_id] % count |
| self._rr_index[pool_id] = (idx + 1) % count |
| return idx |
|
|
| rl_state = RateLimitState() |
|
|
| |
|
|
| ADMIN_KEY = os.getenv("ADMIN_KEY", "admin-changeme") |
|
|
| async def require_admin(authorization: Optional[str] = Header(None)): |
| if not authorization or not authorization.startswith("Bearer "): |
| raise HTTPException(status_code=401, detail="Missing Authorization header") |
| token = authorization[7:] |
| if token != ADMIN_KEY: |
| raise HTTPException(status_code=403, detail="Invalid admin key") |
| return token |
|
|
| async def require_api_key(authorization: Optional[str] = Header(None)): |
| if not authorization or not authorization.startswith("Bearer "): |
| raise HTTPException(status_code=401, detail="Missing Authorization header") |
| token = authorization[7:] |
| if token == ADMIN_KEY: |
| return token |
| data = load_data() |
| for k in data["api_keys"].values(): |
| if k["key"] == token: |
| return token |
| raise HTTPException(status_code=403, detail="Invalid API key") |
|
|
| |
|
|
| async def resolve_endpoint(model_str: str, data: dict): |
| if "/" in model_str: |
| parts = model_str.split("/", 1) |
| provider_id, model_name = parts[0], parts[1] |
| provider = data["providers"].get(provider_id) |
| if not provider: |
| raise HTTPException(status_code=404, detail=f"Provider '{provider_id}' not found") |
| return provider, model_name, provider_id |
|
|
| pool = None |
| pool_id = None |
| for pid, p in data["pool_models"].items(): |
| if p["name"] == model_str or pid == model_str: |
| pool = p |
| pool_id = pid |
| break |
|
|
| if not pool: |
| raise HTTPException(status_code=404, detail=f"Model or pool '{model_str}' not found.") |
|
|
| members = pool.get("members", []) |
| if not members: |
| raise HTTPException(status_code=503, detail=f"Pool '{model_str}' has no members") |
|
|
| strategy = pool.get("strategy", "round_robin") |
|
|
| if strategy == "round_robin": |
| start = await rl_state.next_rr(pool_id, len(members)) |
| for i in range(len(members)): |
| m = members[(start + i) % len(members)] |
| provider = data["providers"].get(m["provider_id"]) |
| if not provider: |
| continue |
| if await rl_state.is_available(m["provider_id"], m["model_name"], provider): |
| return provider, m["model_name"], m["provider_id"] |
|
|
| elif strategy == "priority": |
| for m in members: |
| provider = data["providers"].get(m["provider_id"]) |
| if not provider: |
| continue |
| if await rl_state.is_available(m["provider_id"], m["model_name"], provider): |
| return provider, m["model_name"], m["provider_id"] |
|
|
| elif strategy == "weighted": |
| import random |
| available = [] |
| for m in members: |
| provider = data["providers"].get(m["provider_id"]) |
| if not provider: |
| continue |
| if await rl_state.is_available(m["provider_id"], m["model_name"], provider): |
| available.append((m, provider)) |
| if available: |
| weights = [m[0].get("weight", 1) for m in available] |
| chosen_m, chosen_p = random.choices(available, weights=weights, k=1)[0] |
| return chosen_p, chosen_m["model_name"], chosen_m["provider_id"] |
|
|
| raise HTTPException(status_code=429, detail=f"All endpoints in pool '{model_str}' are currently rate-limited.") |
|
|
| |
|
|
| TIMEOUT = httpx.Timeout(320.0, connect=30.0, read=300.0, write=30.0) |
|
|
| def _extract_retry_after(response: httpx.Response) -> float: |
| ra = response.headers.get("retry-after", "") |
| try: |
| return float(ra) |
| except Exception: |
| return 60.0 |
|
|
| async def proxy_request(provider: dict, model_name: str, provider_id: str, body: dict, stream: bool): |
| base_url = provider["base_url"].rstrip("/") |
| api_key = provider["api_key"] |
| headers = { |
| "Authorization": f"Bearer {api_key}", |
| "Content-Type": "application/json", |
| } |
| body = {**body, "model": model_name} |
| url = f"{base_url}/chat/completions" |
|
|
| await rl_state.record_request(provider_id, model_name) |
|
|
| if stream: |
| async def stream_gen(): |
| async with httpx.AsyncClient(timeout=TIMEOUT) as client: |
| async with client.stream("POST", url, json=body, headers=headers) as resp: |
| if resp.status_code == 429: |
| cooldown = _extract_retry_after(resp) |
| await rl_state.mark_rate_limited(provider_id, model_name, cooldown) |
| yield f"data: {json.dumps({'error': 'rate_limited', 'provider': provider_id})}\n\n" |
| return |
| async for chunk in resp.aiter_bytes(): |
| yield chunk |
| return StreamingResponse(stream_gen(), media_type="text/event-stream") |
|
|
| async with httpx.AsyncClient(timeout=TIMEOUT) as client: |
| resp = await client.post(url, json=body, headers=headers) |
|
|
| if resp.status_code == 429: |
| cooldown = _extract_retry_after(resp) |
| await rl_state.mark_rate_limited(provider_id, model_name, cooldown) |
| raise HTTPException(status_code=429, detail=f"Upstream rate limit hit on {provider_id}/{model_name}. Retry after {cooldown}s.") |
|
|
| if resp.status_code >= 500: |
| raise HTTPException(status_code=502, detail=f"Upstream error from {provider_id}: {resp.status_code}") |
|
|
| if resp.status_code >= 400: |
| try: |
| detail = resp.json() |
| except Exception: |
| detail = resp.text |
| raise HTTPException(status_code=resp.status_code, detail=detail) |
|
|
| return JSONResponse(content=resp.json()) |
|
|
| |
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| log.info("RyzGateway starting up") |
| if not DATA_FILE.exists(): |
| save_data(_default_data()) |
| log.info(f"Initialized empty data at {DATA_FILE}") |
| yield |
| log.info("RyzGateway shutting down") |
|
|
| app = FastAPI(title="RyzGateway", lifespan=lifespan) |
|
|
| |
|
|
| class ChatMessage(BaseModel): |
| role: str |
| content: Any |
|
|
| class ChatRequest(BaseModel): |
| model: str |
| messages: List[ChatMessage] |
| stream: Optional[bool] = False |
| temperature: Optional[float] = None |
| max_tokens: Optional[int] = None |
| top_p: Optional[float] = None |
| model_config = {"extra": "allow"} |
|
|
| @app.post("/v1/chat/completions") |
| async def chat_completions(request: ChatRequest, _key: str = Depends(require_api_key)): |
| data = load_data() |
| provider, model_name, provider_id = await resolve_endpoint(request.model, data) |
| body = request.model_dump(exclude_none=True, exclude={"model"}) |
| body.update(request.model_extra or {}) |
| return await proxy_request(provider, model_name, provider_id, body, request.stream or False) |
|
|
| @app.get("/v1/models") |
| async def list_models(_key: str = Depends(require_api_key)): |
| data = load_data() |
| now = int(time.time()) |
| models = [] |
|
|
| for pid, p in data["providers"].items(): |
| for m in p.get("known_models", []): |
| models.append({ |
| "id": f"{pid}/{m}", |
| "object": "model", |
| "created": now, |
| "owned_by": pid, |
| }) |
|
|
| for pool_id, pool in data["pool_models"].items(): |
| models.append({ |
| "id": pool["name"], |
| "object": "model", |
| "created": now, |
| "owned_by": "pool", |
| }) |
|
|
| return {"object": "list", "data": models} |
|
|
| |
|
|
| @app.get("/admin/api-keys") |
| async def get_api_keys(_=Depends(require_admin)): |
| data = load_data() |
| return list(data["api_keys"].values()) |
|
|
| @app.post("/admin/api-keys") |
| async def create_api_key(body: dict, _=Depends(require_admin)): |
| data = load_data() |
| kid = str(uuid.uuid4())[:8] |
| key = "ryz-" + str(uuid.uuid4()).replace("-", "") |
| data["api_keys"][kid] = { |
| "id": kid, |
| "key": key, |
| "name": body.get("name", "Unnamed Key"), |
| "created_at": int(time.time()), |
| } |
| save_data(data) |
| return data["api_keys"][kid] |
|
|
| @app.delete("/admin/api-keys/{kid}") |
| async def delete_api_key(kid: str, _=Depends(require_admin)): |
| data = load_data() |
| if kid not in data["api_keys"]: |
| raise HTTPException(status_code=404, detail="Key not found") |
| del data["api_keys"][kid] |
| save_data(data) |
| return {"deleted": True} |
|
|
| |
|
|
| @app.get("/admin/providers") |
| async def get_providers(_=Depends(require_admin)): |
| data = load_data() |
| result = [] |
| for pid, p in data["providers"].items(): |
| entry = {**p, "id": pid} |
| raw = entry.get("api_key", "") |
| entry["api_key_masked"] = raw[:6] + "..." + raw[-4:] if len(raw) > 10 else "****" |
| entry.pop("api_key", None) |
| |
| cooldowns = {} |
| for m in p.get("known_models", []): |
| cd = rl_state.get_cooldown_remaining(pid, m) |
| if cd > 0: |
| cooldowns[m] = round(cd, 1) |
| entry["active_cooldowns"] = cooldowns |
| result.append(entry) |
| return result |
|
|
| @app.post("/admin/providers") |
| async def create_provider(body: dict, _=Depends(require_admin)): |
| data = load_data() |
| pid = body.get("id") or re.sub(r"[^a-z0-9_-]", "-", body.get("name", "provider").lower())[:32] |
| if pid in data["providers"]: |
| raise HTTPException(status_code=409, detail=f"Provider ID '{pid}' already exists") |
| data["providers"][pid] = { |
| "name": body.get("name", pid), |
| "base_url": body.get("base_url", ""), |
| "api_key": body.get("api_key", ""), |
| "rate_type": body.get("rate_type", "none"), |
| "rate_limit": int(body.get("rate_limit", 0)), |
| "known_models": body.get("known_models", []), |
| "notes": body.get("notes", ""), |
| } |
| save_data(data) |
| return {"id": pid, **data["providers"][pid], "api_key": "[hidden]"} |
|
|
| @app.put("/admin/providers/{pid}") |
| async def update_provider(pid: str, body: dict, _=Depends(require_admin)): |
| data = load_data() |
| if pid not in data["providers"]: |
| raise HTTPException(status_code=404, detail="Provider not found") |
| p = data["providers"][pid] |
| for field in ["name", "base_url", "rate_type", "notes", "known_models"]: |
| if field in body: |
| p[field] = body[field] |
| if "rate_limit" in body: |
| p["rate_limit"] = int(body["rate_limit"]) |
| if "api_key" in body and body["api_key"] and not body["api_key"].startswith("****"): |
| p["api_key"] = body["api_key"] |
| save_data(data) |
| return {"id": pid, **p, "api_key": "[hidden]"} |
|
|
| @app.delete("/admin/providers/{pid}") |
| async def delete_provider(pid: str, _=Depends(require_admin)): |
| data = load_data() |
| if pid not in data["providers"]: |
| raise HTTPException(status_code=404, detail="Provider not found") |
| del data["providers"][pid] |
| save_data(data) |
| return {"deleted": True} |
|
|
| @app.post("/admin/providers/{pid}/fetch-models") |
| async def fetch_provider_models(pid: str, _=Depends(require_admin)): |
| data = load_data() |
| if pid not in data["providers"]: |
| raise HTTPException(status_code=404, detail="Provider not found") |
| |
| p = data["providers"][pid] |
| base_url = p["base_url"].rstrip("/") |
| api_key = p["api_key"] |
| |
| if not base_url or not api_key: |
| raise HTTPException(status_code=400, detail="Provider base_url or api_key is missing.") |
| |
| headers = { |
| "Authorization": f"Bearer {api_key}", |
| "Content-Type": "application/json", |
| } |
| |
| try: |
| async with httpx.AsyncClient(timeout=15.0) as client: |
| resp = await client.get(f"{base_url}/models", headers=headers) |
| |
| if resp.status_code != 200: |
| raise HTTPException(status_code=502, detail=f"Upstream returned {resp.status_code}: {resp.text[:200]}") |
| |
| resp_data = resp.json() |
| models = [] |
| if isinstance(resp_data, dict) and "data" in resp_data: |
| for item in resp_data["data"]: |
| if "id" in item: |
| models.append(item["id"]) |
| elif isinstance(resp_data, list): |
| for item in resp_data: |
| if isinstance(item, dict) and "id" in item: |
| models.append(item["id"]) |
| elif isinstance(item, str): |
| models.append(item) |
| |
| if not models: |
| raise HTTPException(status_code=404, detail="No models found in upstream /v1/models response.") |
|
|
| |
| unique_models = sorted(list(set(models))) |
| |
| p["known_models"] = unique_models |
| save_data(data) |
| |
| return {"fetched_count": len(unique_models), "models": unique_models} |
| |
| except httpx.RequestError as e: |
| raise HTTPException(status_code=502, detail=f"Network error fetching models: {str(e)}") |
|
|
| |
|
|
| @app.get("/admin/pools") |
| async def get_pools(_=Depends(require_admin)): |
| data = load_data() |
| result = [] |
| for pool_id, pool in data["pool_models"].items(): |
| entry = {**pool, "id": pool_id} |
| annotated = [] |
| for m in pool.get("members", []): |
| provider = data["providers"].get(m["provider_id"], {}) |
| available = await rl_state.is_available(m["provider_id"], m["model_name"], provider) |
| cooldown = rl_state.get_cooldown_remaining(m["provider_id"], m["model_name"]) |
| annotated.append({**m, "available": available, "cooldown_remaining": round(cooldown, 1)}) |
| entry["members"] = annotated |
| result.append(entry) |
| return result |
|
|
| @app.post("/admin/pools") |
| async def create_pool(body: dict, _=Depends(require_admin)): |
| data = load_data() |
| pool_id = body.get("id") or re.sub(r"[^a-z0-9_-]", "-", body.get("name", "pool").lower())[:32] |
| if pool_id in data["pool_models"]: |
| raise HTTPException(status_code=409, detail=f"Pool ID '{pool_id}' already exists") |
| data["pool_models"][pool_id] = { |
| "name": body.get("name", pool_id), |
| "strategy": body.get("strategy", "round_robin"), |
| "members": body.get("members", []), |
| } |
| save_data(data) |
| return {"id": pool_id, **data["pool_models"][pool_id]} |
|
|
| @app.put("/admin/pools/{pool_id}") |
| async def update_pool(pool_id: str, body: dict, _=Depends(require_admin)): |
| data = load_data() |
| if pool_id not in data["pool_models"]: |
| raise HTTPException(status_code=404, detail="Pool not found") |
| p = data["pool_models"][pool_id] |
| for field in ["name", "strategy", "members"]: |
| if field in body: |
| p[field] = body[field] |
| save_data(data) |
| return {"id": pool_id, **p} |
|
|
| @app.delete("/admin/pools/{pool_id}") |
| async def delete_pool(pool_id: str, _=Depends(require_admin)): |
| data = load_data() |
| if pool_id not in data["pool_models"]: |
| raise HTTPException(status_code=404, detail="Pool not found") |
| del data["pool_models"][pool_id] |
| save_data(data) |
| return {"deleted": True} |
|
|
| |
|
|
| @app.get("/admin/status") |
| async def get_status(_=Depends(require_admin)): |
| data = load_data() |
| return { |
| "providers": len(data["providers"]), |
| "pools": len(data["pool_models"]), |
| "api_keys": len(data["api_keys"]), |
| } |
|
|
| |
|
|
| DASHBOARD_HTML = r"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>RyzGateway</title> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } |
| |
| :root { |
| --bg: #0a0a0f; |
| --surface-glass: rgba(20, 20, 30, 0.55); |
| --surface-glass-strong: rgba(30, 30, 45, 0.7); |
| --border-glass: rgba(255, 255, 255, 0.1); |
| --border-glass-strong: rgba(255, 255, 255, 0.2); |
| --text: #f0f0f5; |
| --muted: #8888a0; |
| --accent: #7c6af7; |
| --accent-dim: rgba(124,106,247,0.2); |
| --accent-hover: #9080ff; |
| --green: #4ade80; |
| --green-dim: rgba(74,222,128,0.15); |
| --red: #f87171; |
| --red-dim: rgba(248,113,113,0.15); |
| --amber: #fbbf24; |
| --amber-dim: rgba(251,191,36,0.15); |
| --radius: 10px; |
| --radius-lg: 16px; |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; |
| } |
| |
| html, body { height: 100%; overscroll-behavior: none; } |
| |
| body { |
| background: var(--bg); |
| color: var(--text); |
| min-height: 100vh; |
| background-image: |
| radial-gradient(circle at 0% 0%, rgba(124, 106, 247, 0.15) 0%, transparent 40%), |
| radial-gradient(circle at 100% 100%, rgba(74, 222, 128, 0.1) 0%, transparent 40%); |
| background-attachment: fixed; |
| } |
| |
| /* Layout */ |
| .app { display: flex; min-height: 100vh; } |
| |
| .sidebar { |
| width: 260px; |
| flex-shrink: 0; |
| background: var(--surface-glass); |
| backdrop-filter: blur(20px) saturate(150%); |
| -webkit-backdrop-filter: blur(20px) saturate(150%); |
| border-right: 1px solid var(--border-glass); |
| padding: 1.5rem 0; |
| display: flex; |
| flex-direction: column; |
| position: fixed; |
| top: 0; left: 0; height: 100vh; |
| z-index: 100; |
| transition: transform 0.3s ease; |
| } |
| |
| .main { |
| flex: 1; |
| padding: 2rem; |
| max-width: 960px; |
| margin-left: 260px; |
| } |
| |
| .mobile-header { |
| display: none; |
| position: sticky; |
| top: 0; |
| background: var(--surface-glass); |
| backdrop-filter: blur(20px) saturate(150%); |
| -webkit-backdrop-filter: blur(20px) saturate(150%); |
| border-bottom: 1px solid var(--border-glass); |
| padding: 1rem; |
| z-index: 90; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| .menu-btn { |
| background: rgba(255,255,255,0.1); |
| border: 1px solid var(--border-glass); |
| color: var(--text); |
| width: 40px; height: 40px; |
| border-radius: 10px; |
| display: flex; align-items: center; justify-content: center; |
| cursor: pointer; |
| } |
| |
| /* Sidebar */ |
| .logo { padding: 0 1.25rem 1.5rem; border-bottom: 1px solid var(--border-glass); margin-bottom: 0.75rem; } |
| .logo-text { font-size: 1.2rem; font-weight: 700; color: var(--text); letter-spacing: -0.02em; } |
| .logo-sub { font-size: 0.75rem; color: var(--muted); margin-top: 4px; } |
| .nav-item { display: flex; align-items: center; gap: 12px; padding: 0.75rem 1.25rem; color: var(--muted); cursor: pointer; font-size: 0.9rem; transition: all 0.2s; border-left: 3px solid transparent; } |
| .nav-item:hover { color: var(--text); background: rgba(255,255,255,0.05); } |
| .nav-item.active { color: var(--text); background: var(--accent-dim); border-left-color: var(--accent); } |
| .nav-item svg { width: 18px; height: 18px; flex-shrink: 0; } |
| .nav-section { font-size: 0.7rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; padding: 1.25rem 1.25rem 0.5rem; } |
| |
| /* Auth gate */ |
| #auth-gate { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; z-index: 999; padding: 1rem; } |
| .auth-box { background: var(--surface-glass-strong); backdrop-filter: blur(30px) saturate(150%); -webkit-backdrop-filter: blur(30px) saturate(150%); border: 1px solid var(--border-glass-strong); border-radius: var(--radius-lg); padding: 2rem; width: 100%; max-width: 360px; } |
| .auth-box h2 { font-size: 1.25rem; margin-bottom: 0.5rem; } |
| .auth-box p { color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem; } |
| |
| /* Page header */ |
| .page-header { margin-bottom: 1.75rem; } |
| .page-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; } |
| .page-sub { color: var(--muted); font-size: 0.875rem; margin-top: 4px; } |
| |
| /* Cards */ |
| .card { background: var(--surface-glass); backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); border: 1px solid var(--border-glass); border-radius: var(--radius-lg); padding: 1.25rem; transition: border-color 0.2s; } |
| .card + .card { margin-top: 1rem; } |
| |
| /* Stats row */ |
| .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; } |
| .stat { background: var(--surface-glass); backdrop-filter: blur(15px); border: 1px solid var(--border-glass); border-radius: var(--radius); padding: 1rem; text-align: center; } |
| .stat-val { font-size: 1.5rem; font-weight: 700; color: var(--text); } |
| .stat-label { font-size: 0.7rem; color: var(--muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.05em; } |
| |
| /* Table */ |
| .table-wrap { overflow-x: auto; } |
| table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } |
| th { text-align: left; color: var(--muted); font-weight: 500; font-size: 0.75rem; padding: 0 0.75rem 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; } |
| td { padding: 0.875rem 0.75rem; border-top: 1px solid var(--border-glass); vertical-align: middle; } |
| tr:hover td { background: rgba(255,255,255,0.03); } |
| |
| /* Badges */ |
| .badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 99px; font-size: 0.7rem; font-weight: 600; } |
| .badge-green { background: var(--green-dim); color: var(--green); } |
| .badge-red { background: var(--red-dim); color: var(--red); } |
| .badge-amber { background: var(--amber-dim); color: var(--amber); } |
| .badge-purple { background: var(--accent-dim); color: var(--accent); } |
| .badge-gray { background: rgba(255,255,255,0.08); color: var(--muted); } |
| .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } |
| |
| /* Buttons */ |
| .btn { display: inline-flex; align-items: center; gap: 6px; padding: 0.5rem 1rem; border-radius: var(--radius); border: 1px solid var(--border-glass-strong); background: rgba(255,255,255,0.05); color: var(--text); font-size: 0.85rem; cursor: pointer; transition: all 0.2s; white-space: nowrap; } |
| .btn:hover { background: rgba(255,255,255,0.1); } |
| .btn:active { transform: scale(0.97); } |
| .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } |
| .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } |
| .btn-danger { background: var(--red-dim); border-color: rgba(248,113,113,0.3); color: var(--red); } |
| .btn-sm { padding: 0.35rem 0.7rem; font-size: 0.75rem; } |
| .btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; } |
| |
| /* Forms */ |
| .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } |
| .form-grid.full { grid-template-columns: 1fr; } |
| .field { display: flex; flex-direction: column; gap: 6px; } |
| .field.span2 { grid-column: span 2; } |
| label { font-size: 0.8rem; color: var(--muted); font-weight: 500; } |
| input, select, textarea { background: rgba(0,0,0,0.2); border: 1px solid var(--border-glass-strong); border-radius: var(--radius); color: var(--text); padding: 0.65rem 0.85rem; font-size: 0.9rem; font-family: inherit; width: 100%; transition: border-color 0.2s; } |
| input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); } |
| select option { background: #1a1a25; } |
| textarea { min-height: 70px; resize: vertical; } |
| |
| /* Modal */ |
| .overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(5px); display: flex; align-items: flex-end; justify-content: center; z-index: 500; padding: 0; } |
| .modal { background: var(--surface-glass-strong); backdrop-filter: blur(30px) saturate(150%); -webkit-backdrop-filter: blur(30px) saturate(150%); border: 1px solid var(--border-glass-strong); border-radius: 20px 20px 0 0; width: 100%; max-width: 600px; max-height: 90vh; overflow-y: auto; animation: slideUp 0.3s ease-out; } |
| @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } |
| .modal-header { padding: 1.5rem 1.5rem 1rem; border-bottom: 1px solid var(--border-glass); display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; background: inherit; z-index: 10; } |
| .modal-title { font-size: 1.1rem; font-weight: 600; } |
| .modal-body { padding: 1.5rem; } |
| .modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border-glass); display: flex; justify-content: flex-end; gap: 0.5rem; position: sticky; bottom: 0; background: inherit; } |
| .close-btn { background: rgba(255,255,255,0.05); border: 1px solid var(--border-glass); color: var(--muted); cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 6px 10px; border-radius: 8px; } |
| .close-btn:hover { color: var(--text); background: rgba(255,255,255,0.1); } |
| |
| /* Members list in pool */ |
| .member-item { display: flex; align-items: center; gap: 8px; background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); border-radius: var(--radius); padding: 0.65rem 0.85rem; margin-bottom: 0.5rem; } |
| .member-item .member-label { flex: 1; font-size: 0.85rem; word-break: break-all; } |
| .member-badge { font-size: 0.75rem; color: var(--muted); } |
| |
| /* Code / key display */ |
| .key-display { font-family: 'SF Mono', Consolas, monospace; font-size: 0.8rem; background: rgba(0,0,0,0.3); border: 1px solid var(--border-glass); border-radius: var(--radius); padding: 0.65rem 0.85rem; word-break: break-all; color: var(--green); } |
| .code-block { font-family: 'SF Mono', Consolas, monospace; font-size: 0.8rem; background: rgba(0,0,0,0.3); border: 1px solid var(--border-glass); border-radius: var(--radius); padding: 1rem; overflow-x: auto; line-height: 1.6; } |
| |
| /* Toast */ |
| #toast { position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); background: var(--surface-glass-strong); backdrop-filter: blur(20px); border: 1px solid var(--border-glass-strong); border-radius: 99px; padding: 0.75rem 1.5rem; font-size: 0.85rem; z-index: 9999; opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none; max-width: 90%; text-align: center; } |
| #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } |
| |
| /* Empty state */ |
| .empty { text-align: center; padding: 3rem 1rem; color: var(--muted); font-size: 0.9rem; } |
| |
| /* Sections */ |
| .section { display: none; } |
| .section.active { display: block; } |
| |
| /* Callout */ |
| .callout { background: var(--accent-dim); border: 1px solid rgba(124,106,247,0.3); border-radius: var(--radius); padding: 1rem; font-size: 0.85rem; color: var(--text); margin-bottom: 1.25rem; line-height: 1.6; } |
| .callout code { background: rgba(0,0,0,0.3); border-radius: 4px; padding: 2px 6px; font-family: monospace; font-size: 0.8rem; color: var(--accent); } |
| |
| /* Mobile optimizations */ |
| @media (max-width: 768px) { |
| .sidebar { transform: translateX(-100%); box-shadow: 5px 0 30px rgba(0,0,0,0.5); } |
| .sidebar.open { transform: translateX(0); } |
| .main { margin-left: 0; padding: 1rem; padding-bottom: 3rem; } |
| .mobile-header { display: flex; } |
| .form-grid { grid-template-columns: 1fr; } |
| .field.span2 { grid-column: span 1; } |
| .page-title { font-size: 1.25rem; } |
| .stats { gap: 0.5rem; } |
| .stat-val { font-size: 1.25rem; } |
| .card { padding: 1rem; } |
| th, td { padding: 0.65rem 0.5rem; font-size: 0.8rem; } |
| .hide-mobile { display: none; } |
| } |
| |
| /* Prevent body scroll when menu open */ |
| body.menu-open { overflow: hidden; } |
| .menu-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; } |
| body.menu-open .menu-overlay { display: block; } |
| </style> |
| </head> |
| <body> |
| |
| <div id="auth-gate"> |
| <div class="auth-box"> |
| <h2>RyzGateway</h2> |
| <p>Enter your admin key to access the dashboard.</p> |
| <div class="field" style="margin-bottom:1rem"> |
| <label>Admin Key</label> |
| <input type="password" id="login-key" placeholder="sk-admin-..." /> |
| </div> |
| <button class="btn btn-primary" style="width:100%; justify-content:center;" onclick="doLogin()">Sign in</button> |
| <div id="login-err" style="color:var(--red);font-size:0.8rem;margin-top:0.75rem;display:none">Invalid key.</div> |
| </div> |
| </div> |
| |
| <div class="app" id="app-shell" style="display:none"> |
| <div class="menu-overlay" onclick="toggleMenu()"></div> |
| |
| <aside class="sidebar" id="sidebar"> |
| <div class="logo"> |
| <div class="logo-text">RyzGateway</div> |
| <div class="logo-sub">BYOK AI Router</div> |
| </div> |
| <div class="nav-section">Overview</div> |
| <div class="nav-item active" onclick="nav('overview', this)"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg> |
| Dashboard |
| </div> |
| <div class="nav-section">Configure</div> |
| <div class="nav-item" onclick="nav('providers', this)"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg> |
| Providers |
| </div> |
| <div class="nav-item" onclick="nav('pools', this)"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> |
| Model Pools |
| </div> |
| <div class="nav-item" onclick="nav('keys', this)"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg> |
| API Keys |
| </div> |
| <div class="nav-section">Reference</div> |
| <div class="nav-item" onclick="nav('docs', this)"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> |
| Usage Docs |
| </div> |
| </aside> |
| |
| <main class="main"> |
| <div class="mobile-header"> |
| <button class="menu-btn" onclick="toggleMenu()"> |
| <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg> |
| </button> |
| <div style="font-weight:600">RyzGateway</div> |
| <div style="width:40px"></div> |
| </div> |
| |
| <!-- OVERVIEW --> |
| <div class="section active" id="sec-overview"> |
| <div class="page-header"> |
| <div class="page-title">Dashboard</div> |
| <div class="page-sub">Gateway status and routing health</div> |
| </div> |
| <div class="stats" id="stats-row"> |
| <div class="stat"><div class="stat-val" id="stat-providers">β</div><div class="stat-label">Providers</div></div> |
| <div class="stat"><div class="stat-val" id="stat-pools">β</div><div class="stat-label">Pools</div></div> |
| <div class="stat"><div class="stat-val" id="stat-keys">β</div><div class="stat-label">Keys</div></div> |
| </div> |
| <div class="card"> |
| <div style="font-size:0.9rem;font-weight:600;margin-bottom:1rem">Provider health</div> |
| <div id="health-list"><div class="empty">No providers configured</div></div> |
| </div> |
| </div> |
| |
| <!-- PROVIDERS --> |
| <div class="section" id="sec-providers"> |
| <div class="page-header"> |
| <div class="page-title">Providers</div> |
| <div class="page-sub">API endpoints with rate limit configuration</div> |
| </div> |
| <div class="btn-row"> |
| <button class="btn btn-primary" onclick="openProviderModal()">+ Add provider</button> |
| <button class="btn" onclick="loadProviders()">βΊ Refresh</button> |
| </div> |
| <div class="callout"> |
| <strong>Provider ID</strong> is auto-generated from the name and used in model routing. Use <code>provider-id/exact-model-name</code> to route directly. Click <strong>Fetch Models</strong> on a provider to auto-import available models. |
| </div> |
| <div class="card"> |
| <div class="table-wrap"> |
| <table> |
| <thead><tr> |
| <th>Provider</th><th class="hide-mobile">Base URL</th><th>Rate limit</th><th>Models</th><th>Status</th><th></th> |
| </tr></thead> |
| <tbody id="providers-tbody"><tr><td colspan="6"><div class="empty">Loading...</div></td></tr></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| |
| <!-- POOLS --> |
| <div class="section" id="sec-pools"> |
| <div class="page-header"> |
| <div class="page-title">Model Pools</div> |
| <div class="page-sub">Generic model names that route across multiple providers</div> |
| </div> |
| <div class="btn-row"> |
| <button class="btn btn-primary" onclick="openPoolModal()">+ Add pool</button> |
| <button class="btn" onclick="loadPools()">βΊ Refresh</button> |
| </div> |
| <div class="callout"> |
| Pool names are generic model names exposed in <code>/v1/models</code>. The gateway routes requests using your chosen strategy and automatically skips rate-limited endpoints. |
| </div> |
| <div id="pools-list"><div class="empty">Loading...</div></div> |
| </div> |
| |
| <!-- API KEYS --> |
| <div class="section" id="sec-keys"> |
| <div class="page-header"> |
| <div class="page-title">API Keys</div> |
| <div class="page-sub">Keys issued to clients for /v1/ access</div> |
| </div> |
| <div class="btn-row"> |
| <button class="btn btn-primary" onclick="openKeyModal()">+ Create key</button> |
| <button class="btn" onclick="loadKeys()">βΊ Refresh</button> |
| </div> |
| <div class="card"> |
| <div class="table-wrap"> |
| <table> |
| <thead><tr><th>Name</th><th>Key</th><th class="hide-mobile">Created</th><th></th></tr></thead> |
| <tbody id="keys-tbody"><tr><td colspan="4"><div class="empty">Loading...</div></td></tr></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| |
| <!-- DOCS --> |
| <div class="section" id="sec-docs"> |
| <div class="page-header"> |
| <div class="page-title">Usage docs</div> |
| <div class="page-sub">How to use RyzGateway in your apps</div> |
| </div> |
| <div class="card" style="margin-bottom:1rem"> |
| <div style="font-size:0.9rem;font-weight:600;margin-bottom:0.75rem">Base URL</div> |
| <div class="key-display" id="base-url-display"></div> |
| <div style="font-size:0.8rem;color:var(--muted);margin-top:0.5rem">Point any OpenAI-compatible client at this URL.</div> |
| </div> |
| <div class="card" style="margin-bottom:1rem"> |
| <div style="font-size:0.9rem;font-weight:600;margin-bottom:0.75rem">Model name formats</div> |
| <div style="font-size:0.85rem;line-height:1.8;color:var(--text)"> |
| <div style="margin-bottom:0.75rem"><span style="font-family:monospace;background:rgba(0,0,0,0.3);padding:3px 8px;border-radius:6px;color:var(--accent)">provider-id/exact-model-name</span> β Direct passthrough.</div> |
| <div><span style="font-family:monospace;background:rgba(0,0,0,0.3);padding:3px 8px;border-radius:6px;color:var(--green)">pool-name</span> β Pool routing.</div> |
| </div> |
| </div> |
| <div class="card" style="margin-bottom:1rem"> |
| <div style="font-size:0.9rem;font-weight:600;margin-bottom:0.75rem">Example: Python (openai SDK)</div> |
| <pre class="code-block"><code>import openai |
| |
| client = openai.OpenAI( |
| base_url="<span id="doc-base-url"></span>", |
| api_key="YOUR_RYZGATEWAY_API_KEY", |
| ) |
| |
| # Direct provider routing |
| resp = client.chat.completions.create( |
| model="openrouter/google/gemini-flash-1.5", |
| messages=[{"role": "user", "content": "Hello!"}] |
| ) |
| |
| # Pool routing (auto-selects available endpoint) |
| resp = client.chat.completions.create( |
| model="fast-model", |
| messages=[{"role": "user", "content": "Hello!"}] |
| )</code></pre> |
| </div> |
| <div class="card"> |
| <div style="font-size:0.9rem;font-weight:600;margin-bottom:0.75rem">Rate limit types</div> |
| <table style="font-size:0.85rem"> |
| <thead><tr><th>Type</th><th>Meaning</th><th>Window</th></tr></thead> |
| <tbody> |
| <tr><td><span class="badge badge-purple">RPM</span></td><td>Requests per minute</td><td>60s</td></tr> |
| <tr><td><span class="badge badge-green">RPS</span></td><td>Requests per second</td><td>1s</td></tr> |
| <tr><td><span class="badge badge-amber">RPD</span></td><td>Requests per day</td><td>86400s</td></tr> |
| <tr><td><span class="badge badge-gray">None</span></td><td>No limit tracked</td><td>β</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| |
| </main> |
| </div> |
| |
| <!-- Modals --> |
| <div class="overlay" id="modal-overlay" style="display:none" onclick="if(event.target===this)closeModal()"> |
| <div class="modal" id="modal-box"> |
| <div class="modal-header"> |
| <div class="modal-title" id="modal-title">Modal</div> |
| <button class="close-btn" onclick="closeModal()">β</button> |
| </div> |
| <div class="modal-body" id="modal-body"></div> |
| <div class="modal-footer" id="modal-footer"></div> |
| </div> |
| </div> |
| |
| <div id="toast"></div> |
| |
| <script> |
| let ADMIN_KEY = ''; |
| const BASE = window.location.origin; |
| |
| // ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function doLogin() { |
| const k = document.getElementById('login-key').value.trim(); |
| const err = document.getElementById('login-err'); |
| err.style.display = 'none'; |
| try { |
| const r = await fetch('/admin/status', { headers: { Authorization: 'Bearer ' + k } }); |
| if (!r.ok) { err.style.display = 'block'; return; } |
| ADMIN_KEY = k; |
| document.getElementById('auth-gate').style.display = 'none'; |
| document.getElementById('app-shell').style.display = 'flex'; |
| document.getElementById('base-url-display').textContent = BASE; |
| document.getElementById('doc-base-url').textContent = BASE + '/v1'; |
| initApp(); |
| } catch(e) { err.style.display = 'block'; } |
| } |
| document.getElementById('login-key').addEventListener('keydown', e => { if(e.key === 'Enter') doLogin(); }); |
| |
| async function api(method, path, body) { |
| const opts = { method, headers: { 'Authorization': 'Bearer ' + ADMIN_KEY, 'Content-Type': 'application/json' } }; |
| if (body) opts.body = JSON.stringify(body); |
| const r = await fetch(path, opts); |
| if (!r.ok) { |
| const t = await r.text(); |
| let msg = t; |
| try { const j = JSON.parse(t); msg = j.detail || t; } catch(e) {} |
| throw new Error(msg); |
| } |
| return r.json(); |
| } |
| |
| // ββ Navigation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| function toggleMenu() { |
| document.body.classList.toggle('menu-open'); |
| document.getElementById('sidebar').classList.toggle('open'); |
| } |
| |
| function nav(id, el) { |
| document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); |
| document.getElementById('sec-' + id).classList.add('active'); |
| if(el) el.classList.add('active'); |
| if (window.innerWidth <= 768) toggleMenu(); |
| if (id === 'overview') loadOverview(); |
| if (id === 'providers') loadProviders(); |
| if (id === 'pools') loadPools(); |
| if (id === 'keys') loadKeys(); |
| } |
| |
| function initApp() { |
| loadOverview(); |
| loadProviders(); |
| loadPools(); |
| loadKeys(); |
| } |
| |
| // ββ Toast ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| let toastTimer; |
| function toast(msg, type='ok') { |
| const el = document.getElementById('toast'); |
| el.textContent = msg; |
| el.style.color = type === 'err' ? 'var(--red)' : 'var(--text)'; |
| el.style.borderColor = type === 'err' ? 'var(--red-dim)' : 'var(--border-glass-strong)'; |
| el.classList.add('show'); |
| clearTimeout(toastTimer); |
| toastTimer = setTimeout(() => el.classList.remove('show'), 3500); |
| } |
| |
| // ββ Modal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| function openModal(title, bodyHTML, footerHTML) { |
| document.getElementById('modal-title').textContent = title; |
| document.getElementById('modal-body').innerHTML = bodyHTML; |
| document.getElementById('modal-footer').innerHTML = footerHTML; |
| document.getElementById('modal-overlay').style.display = 'flex'; |
| } |
| function closeModal() { document.getElementById('modal-overlay').style.display = 'none'; } |
| |
| // ββ Overview βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function loadOverview() { |
| try { |
| const [status, providers] = await Promise.all([api('GET', '/admin/status'), api('GET', '/admin/providers')]); |
| document.getElementById('stat-providers').textContent = status.providers; |
| document.getElementById('stat-pools').textContent = status.pools; |
| document.getElementById('stat-keys').textContent = status.api_keys; |
| |
| const el = document.getElementById('health-list'); |
| if (!providers.length) { el.innerHTML = '<div class="empty">No providers configured</div>'; return; } |
| el.innerHTML = providers.map(p => { |
| const cds = Object.entries(p.active_cooldowns || {}); |
| const status = cds.length |
| ? `<span class="badge badge-amber"><span class="dot"></span>${cds.length} cooling</span>` |
| : `<span class="badge badge-green"><span class="dot"></span>OK</span>`; |
| const rl = p.rate_type !== 'none' ? `<span class="badge badge-gray">${p.rate_limit} ${p.rate_type.toUpperCase()}</span>` : '<span class="badge badge-gray">no limit</span>'; |
| return `<div style="display:flex;align-items:center;gap:12px;padding:0.75rem 0;border-bottom:1px solid var(--border-glass)"> |
| <div style="flex:1;font-size:0.9rem;font-weight:500">${p.name} <span style="color:var(--muted);font-weight:400;font-size:0.8rem">${p.id}</span></div> |
| <div>${rl}</div> |
| <div>${status}</div> |
| </div>`; |
| }).join(''); |
| } catch(e) { console.error(e); } |
| } |
| |
| // ββ Providers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function loadProviders() { |
| try { |
| const providers = await api('GET', '/admin/providers'); |
| const tbody = document.getElementById('providers-tbody'); |
| if (!providers.length) { tbody.innerHTML = '<tr><td colspan="6"><div class="empty">No providers yet</div></td></tr>'; return; } |
| tbody.innerHTML = providers.map(p => { |
| const rl = p.rate_type !== 'none' ? `${p.rate_limit} ${p.rate_type.toUpperCase()}` : 'β'; |
| const cds = Object.entries(p.active_cooldowns || {}); |
| const statusBadge = cds.length |
| ? `<span class="badge badge-amber"><span class="dot"></span>${cds.length}</span>` |
| : `<span class="badge badge-green"><span class="dot"></span>OK</span>`; |
| const models = (p.known_models || []).length; |
| return `<tr> |
| <td><div style="font-weight:500">${p.name}</div><div style="font-size:0.75rem;color:var(--muted)">${p.id}</div></td> |
| <td class="hide-mobile" style="font-size:0.8rem;color:var(--muted);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.base_url}</td> |
| <td style="font-size:0.8rem">${rl}</td> |
| <td style="font-size:0.8rem">${models}</td> |
| <td>${statusBadge}</td> |
| <td style="text-align:right; white-space:nowrap;"> |
| <button class="btn btn-sm" onclick='fetchModels("${p.id}")' title="Auto-fetch models from provider">Fetch</button> |
| <button class="btn btn-sm" onclick='editProvider(${JSON.stringify(p)})'>Edit</button> |
| <button class="btn btn-sm btn-danger" onclick="deleteProvider('${p.id}','${p.name}')">Del</button> |
| </td> |
| </tr>`; |
| }).join(''); |
| } catch(e) { console.error(e); } |
| } |
| |
| async function fetchModels(pid) { |
| toast('Fetching models...'); |
| try { |
| const res = await api('POST', `/admin/providers/${pid}/fetch-models`); |
| toast(`Successfully imported ${res.fetched_count} models!`); |
| loadProviders(); |
| loadOverview(); |
| } catch(e) { |
| toast('Fetch failed: ' + e.message, 'err'); |
| } |
| } |
| |
| function openProviderModal(existing) { |
| const p = existing || {}; |
| const isEdit = !!p.id; |
| const body = ` |
| <div class="form-grid"> |
| <div class="field"><label>Provider name</label><input id="p-name" value="${p.name||''}" placeholder="OpenRouter"></div> |
| <div class="field"><label>Provider ID</label><input id="p-id" value="${p.id||''}" placeholder="auto-generated" ${isEdit?'disabled':''}></div> |
| <div class="field span2"><label>Base URL</label><input id="p-url" value="${p.base_url||''}" placeholder="https://openrouter.ai/api/v1"></div> |
| <div class="field span2"><label>API Key</label><input id="p-key" type="password" value="" placeholder="${isEdit?'Leave blank to keep current':'sk-...'}"></div> |
| <div class="field"><label>Rate limit type</label> |
| <select id="p-rtype"> |
| <option value="none" ${p.rate_type==='none'||!p.rate_type?'selected':''}>None</option> |
| <option value="rpm" ${p.rate_type==='rpm'?'selected':''}>RPM (per minute)</option> |
| <option value="rps" ${p.rate_type==='rps'?'selected':''}>RPS (per second)</option> |
| <option value="rpd" ${p.rate_type==='rpd'?'selected':''}>RPD (per day)</option> |
| </select> |
| </div> |
| <div class="field"><label>Rate limit value</label><input id="p-rlimit" type="number" value="${p.rate_limit||0}" min="0"></div> |
| <div class="field span2"><label>Known models (comma-separated)</label><input id="p-models" value="${(p.known_models||[]).join(', ')}" placeholder="gpt-4o, gpt-4o-mini, ..."></div> |
| <div class="field span2"><label>Notes</label><textarea id="p-notes">${p.notes||''}</textarea></div> |
| </div> |
| <div class="callout" style="margin-top:1rem;font-size:0.8rem"> |
| Tip: Save the provider first, then click "Fetch" on the providers list to automatically pull and populate the models list. |
| </div>`; |
| const footer = ` |
| <button class="btn" onclick="closeModal()">Cancel</button> |
| <button class="btn btn-primary" onclick="saveProvider(${isEdit?`'${p.id}'`:'null'})">${isEdit?'Save changes':'Add provider'}</button>`; |
| openModal(isEdit ? 'Edit provider' : 'Add provider', body, footer); |
| } |
| |
| function editProvider(p) { openProviderModal(p); } |
| |
| async function saveProvider(existingId) { |
| const name = document.getElementById('p-name').value.trim(); |
| const id = document.getElementById('p-id').value.trim() || undefined; |
| const base_url = document.getElementById('p-url').value.trim(); |
| const api_key = document.getElementById('p-key').value.trim(); |
| const rate_type = document.getElementById('p-rtype').value; |
| const rate_limit = parseInt(document.getElementById('p-rlimit').value) || 0; |
| const known_models = document.getElementById('p-models').value.split(',').map(s=>s.trim()).filter(Boolean); |
| const notes = document.getElementById('p-notes').value.trim(); |
| if (!name || !base_url) { toast('Name and Base URL are required', 'err'); return; } |
| try { |
| if (existingId) { |
| await api('PUT', `/admin/providers/${existingId}`, { name, base_url, api_key, rate_type, rate_limit, known_models, notes }); |
| toast('Provider updated'); |
| } else { |
| await api('POST', '/admin/providers', { name, id, base_url, api_key, rate_type, rate_limit, known_models, notes }); |
| toast('Provider added'); |
| } |
| closeModal(); |
| loadProviders(); |
| loadOverview(); |
| } catch(e) { toast('Error: ' + e.message, 'err'); } |
| } |
| |
| async function deleteProvider(id, name) { |
| if (!confirm(`Delete provider "${name}"?`)) return; |
| try { await api('DELETE', `/admin/providers/${id}`); toast('Deleted'); loadProviders(); loadOverview(); } |
| catch(e) { toast('Error: ' + e.message, 'err'); } |
| } |
| |
| // ββ Pools ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| let providersCache = []; |
| |
| async function loadPools() { |
| try { |
| const [pools, providers] = await Promise.all([api('GET', '/admin/pools'), api('GET', '/admin/providers')]); |
| providersCache = providers; |
| const el = document.getElementById('pools-list'); |
| if (!pools.length) { el.innerHTML = '<div class="empty">No pools yet. Add a pool to create a generic routed model.</div>'; return; } |
| el.innerHTML = pools.map(pool => { |
| const stratBadge = {round_robin:'<span class="badge badge-purple">Round robin</span>', priority:'<span class="badge badge-amber">Priority</span>', weighted:'<span class="badge badge-green">Weighted</span>'}[pool.strategy] || ''; |
| const members = (pool.members||[]).map(m => { |
| const avail = m.available; |
| const cd = m.cooldown_remaining > 0 ? ` (${m.cooldown_remaining}s)` : ''; |
| const badge = avail ? '<span class="badge badge-green"><span class="dot"></span>OK</span>' : `<span class="badge badge-red"><span class="dot"></span>RL${cd}</span>`; |
| return `<div class="member-item"> |
| <div class="member-label"><strong>${m.provider_id}</strong> / ${m.model_name}</div> |
| ${m.weight !== undefined ? `<span class="member-badge">w:${m.weight}</span>` : ''} |
| ${badge} |
| </div>`; |
| }).join(''); |
| return `<div class="card" style="margin-bottom:1rem"> |
| <div style="display:flex;align-items:center;gap:10px;margin-bottom:1rem;flex-wrap:wrap"> |
| <div style="flex:1;min-width: 150px"> |
| <div style="font-weight:600;font-size:1rem">${pool.name}</div> |
| <div style="font-size:0.8rem;color:var(--muted)">${pool.id}</div> |
| </div> |
| ${stratBadge} |
| <button class="btn btn-sm" onclick='openPoolModal(${JSON.stringify(pool)})'>Edit</button> |
| <button class="btn btn-sm btn-danger" onclick="deletePool('${pool.id}','${pool.name}')">Del</button> |
| </div> |
| ${members || '<div style="color:var(--muted);font-size:0.85rem">No members</div>'} |
| </div>`; |
| }).join(''); |
| } catch(e) { console.error(e); } |
| } |
| |
| let poolMembers = []; |
| |
| function openPoolModal(existing) { |
| const p = existing || {}; |
| const isEdit = !!p.id; |
| poolMembers = JSON.parse(JSON.stringify(p.members || [])); |
| |
| const provOpts = providersCache.map(pr => |
| `<option value="${pr.id}">${pr.name} (${pr.id})</option>` |
| ).join(''); |
| |
| const body = ` |
| <div class="form-grid" style="margin-bottom:1rem"> |
| <div class="field"><label>Pool name (used as model ID)</label><input id="pool-name" value="${p.name||''}" placeholder="fast-model"></div> |
| <div class="field"><label>Strategy</label> |
| <select id="pool-strat"> |
| <option value="round_robin" ${p.strategy==='round_robin'||!p.strategy?'selected':''}>Round robin</option> |
| <option value="priority" ${p.strategy==='priority'?'selected':''}>Priority (top-down)</option> |
| <option value="weighted" ${p.strategy==='weighted'?'selected':''}>Weighted random</option> |
| </select> |
| </div> |
| </div> |
| <div style="font-size:0.8rem;color:var(--muted);font-weight:500;margin-bottom:0.5rem">MEMBERS</div> |
| <div id="pool-members-list"></div> |
| <div style="background:rgba(0,0,0,0.2);border:1px solid var(--border-glass);border-radius:var(--radius);padding:1rem;margin-top:0.5rem"> |
| <div style="font-size:0.8rem;color:var(--muted);margin-bottom:0.75rem">Add member</div> |
| <div class="form-grid" style="grid-template-columns: 1fr 1.5fr auto; gap:8px;align-items:end"> |
| <div class="field"><label>Provider</label><select id="new-m-prov">${provOpts||'<option>No providers</option>'}</select></div> |
| <div class="field"><label>Model name</label><input id="new-m-model" placeholder="google/gemini-flash-1.5"></div> |
| <div class="field"><label>Wgt</label><input id="new-m-weight" type="number" value="1" min="1" style="width:70px"></div> |
| </div> |
| <button class="btn btn-sm" style="margin-top:10px;width:100%;justify-content:center" onclick="addPoolMember()">+ Add Member</button> |
| </div>`; |
| |
| const footer = ` |
| <button class="btn" onclick="closeModal()">Cancel</button> |
| <button class="btn btn-primary" onclick="savePool(${isEdit?`'${p.id}'`:'null'})">${isEdit?'Save changes':'Create pool'}</button>`; |
| |
| openModal(isEdit ? 'Edit pool' : 'Create model pool', body, footer); |
| renderPoolMembers(); |
| } |
| |
| function renderPoolMembers() { |
| const el = document.getElementById('pool-members-list'); |
| if (!el) return; |
| if (!poolMembers.length) { el.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;margin-bottom:0.5rem">No members yet</div>'; return; } |
| el.innerHTML = poolMembers.map((m, i) => |
| `<div class="member-item"> |
| <div class="member-label">${m.provider_id} / ${m.model_name}</div> |
| <span class="member-badge">w:${m.weight||1}</span> |
| <button class="btn btn-sm btn-danger" onclick="removePoolMember(${i})">β</button> |
| </div>` |
| ).join(''); |
| } |
| |
| function addPoolMember() { |
| const prov = document.getElementById('new-m-prov').value; |
| const model = document.getElementById('new-m-model').value.trim(); |
| const weight = parseInt(document.getElementById('new-m-weight').value) || 1; |
| if (!prov || !model) { toast('Provider and model name required', 'err'); return; } |
| poolMembers.push({ provider_id: prov, model_name: model, weight }); |
| document.getElementById('new-m-model').value = ''; |
| renderPoolMembers(); |
| } |
| |
| function removePoolMember(i) { |
| poolMembers.splice(i, 1); |
| renderPoolMembers(); |
| } |
| |
| async function savePool(existingId) { |
| const name = document.getElementById('pool-name').value.trim(); |
| const strategy = document.getElementById('pool-strat').value; |
| if (!name) { toast('Pool name is required', 'err'); return; } |
| try { |
| const payload = { name, strategy, members: poolMembers }; |
| if (existingId) { |
| await api('PUT', `/admin/pools/${existingId}`, payload); |
| toast('Pool updated'); |
| } else { |
| await api('POST', '/admin/pools', payload); |
| toast('Pool created'); |
| } |
| closeModal(); |
| loadPools(); |
| } catch(e) { toast('Error: ' + e.message, 'err'); } |
| } |
| |
| async function deletePool(id, name) { |
| if (!confirm(`Delete pool "${name}"?`)) return; |
| try { await api('DELETE', `/admin/pools/${id}`); toast('Deleted'); loadPools(); } |
| catch(e) { toast('Error: ' + e.message, 'err'); } |
| } |
| |
| // ββ API Keys ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function loadKeys() { |
| try { |
| const keys = await api('GET', '/admin/api-keys'); |
| const tbody = document.getElementById('keys-tbody'); |
| if (!keys.length) { tbody.innerHTML = '<tr><td colspan="4"><div class="empty">No keys yet</div></td></tr>'; return; } |
| tbody.innerHTML = keys.map(k => |
| `<tr> |
| <td style="font-weight:500">${k.name}</td> |
| <td><div class="key-display">${k.key}</div></td> |
| <td class="hide-mobile" style="color:var(--muted);font-size:0.8rem">${new Date(k.created_at*1000).toLocaleDateString()}</td> |
| <td style="text-align:right"><button class="btn btn-sm btn-danger" onclick="deleteKey('${k.id}','${k.name}')">Delete</button></td> |
| </tr>` |
| ).join(''); |
| } catch(e) { console.error(e); } |
| } |
| |
| function openKeyModal() { |
| openModal('Create API key', |
| `<div class="field"><label>Key name / description</label><input id="key-name" placeholder="My SillyTavern instance"></div>`, |
| `<button class="btn" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="createKey()">Create</button>` |
| ); |
| } |
| |
| async function createKey() { |
| const name = document.getElementById('key-name').value.trim(); |
| if (!name) { toast('Name required', 'err'); return; } |
| try { |
| await api('POST', '/admin/api-keys', { name }); |
| closeModal(); |
| toast('Key created'); |
| loadKeys(); |
| } catch(e) { toast('Error: ' + e.message, 'err'); } |
| } |
| |
| async function deleteKey(id, name) { |
| if (!confirm(`Delete key "${name}"?`)) return; |
| try { await api('DELETE', `/admin/api-keys/${id}`); toast('Deleted'); loadKeys(); } |
| catch(e) { toast('Error: ' + e.message, 'err'); } |
| } |
| </script> |
| </body> |
| </html>""" |
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def dashboard(): |
| return DASHBOARD_HTML |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False) |