Spaces:
Running on Zero
Running on Zero
| """ | |
| Frox AI β Developer API Gateway | |
| A thin, OpenAI-compatible gateway that sits IN FRONT of the Morph model | |
| server (api/server.py) and adds the pieces a public developer API needs | |
| but the raw model server intentionally doesn't have: | |
| * API key issuance + revocation (POST/DELETE /dev/keys) | |
| * Bearer-token authentication (Authorization: Bearer frx-...) | |
| * Per-key rate limiting (requests/min, token budgets) | |
| * Usage tracking (GET /dev/usage) | |
| It forwards authenticated /v1/* calls to the upstream Morph server | |
| unchanged β so every existing OpenAI-compatible client keeps working, | |
| they just point at the gateway's URL and send their Frox API key. | |
| Run (gateway on 8080, model server on 8000): | |
| python scripts/serve.py --family classic --model ./ckpt --port 8000 | |
| MORPH_UPSTREAM_URL=http://localhost:8000 \ | |
| uvicorn api.developer_gateway:app --host 0.0.0.0 --port 8080 | |
| Storage is a local JSON file by default (MORPH_KEYS_PATH). Swap | |
| `KeyStore` for a real database in production β the interface is small. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import secrets | |
| import threading | |
| import time | |
| from collections import defaultdict, deque | |
| from dataclasses import dataclass, field, asdict | |
| from pathlib import Path | |
| from typing import Deque, Dict, Optional | |
| import httpx | |
| from fastapi import Depends, FastAPI, Header, HTTPException, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse, StreamingResponse | |
| from pydantic import BaseModel, Field | |
| UPSTREAM_URL = os.environ.get("MORPH_UPSTREAM_URL", "http://localhost:8000").rstrip("/") | |
| KEYS_PATH = os.environ.get("MORPH_KEYS_PATH", "./data/api_keys.json") | |
| KEY_PREFIX = "frx-" | |
| # ββ Key model + storage βββββββββββββββββββββββββββββββββββββββββββββ | |
| class ApiKey: | |
| key: str | |
| name: str | |
| plan: str = "free" | |
| created_at: float = field(default_factory=time.time) | |
| revoked: bool = False | |
| # usage counters (cumulative) | |
| total_requests: int = 0 | |
| total_prompt_tokens: int = 0 | |
| total_completion_tokens: int = 0 | |
| # per-plan limits | |
| def rpm_limit(self) -> int: | |
| return {"free": 20, "pro": 120, "scale": 600}.get(self.plan, 20) | |
| class KeyStore: | |
| """JSON-backed key store. Thread-safe for the gateway's needs.""" | |
| def __init__(self, path: str): | |
| self.path = Path(path) | |
| self._lock = threading.Lock() | |
| self._keys: Dict[str, ApiKey] = {} | |
| self._load() | |
| def _load(self): | |
| if self.path.exists(): | |
| try: | |
| raw = json.loads(self.path.read_text()) | |
| self._keys = {k: ApiKey(**v) for k, v in raw.items()} | |
| except (json.JSONDecodeError, TypeError): | |
| self._keys = {} | |
| def _flush(self): | |
| self.path.parent.mkdir(parents=True, exist_ok=True) | |
| tmp = self.path.with_suffix(".tmp") | |
| tmp.write_text(json.dumps({k: asdict(v) for k, v in self._keys.items()}, indent=2)) | |
| tmp.replace(self.path) | |
| def create(self, name: str, plan: str = "free") -> ApiKey: | |
| with self._lock: | |
| token = KEY_PREFIX + secrets.token_urlsafe(32) | |
| api_key = ApiKey(key=token, name=name, plan=plan) | |
| self._keys[token] = api_key | |
| self._flush() | |
| return api_key | |
| def get(self, token: str) -> Optional[ApiKey]: | |
| return self._keys.get(token) | |
| def revoke(self, token: str) -> bool: | |
| with self._lock: | |
| k = self._keys.get(token) | |
| if not k: | |
| return False | |
| k.revoked = True | |
| self._flush() | |
| return True | |
| def record_usage(self, token: str, prompt_tokens: int, completion_tokens: int): | |
| with self._lock: | |
| k = self._keys.get(token) | |
| if not k: | |
| return | |
| k.total_requests += 1 | |
| k.total_prompt_tokens += prompt_tokens | |
| k.total_completion_tokens += completion_tokens | |
| self._flush() | |
| def all(self): | |
| return list(self._keys.values()) | |
| store = KeyStore(KEYS_PATH) | |
| # ββ Rate limiter (sliding window, in-memory) ββββββββββββββββββββββββββββ | |
| class RateLimiter: | |
| def __init__(self): | |
| self._hits: Dict[str, Deque[float]] = defaultdict(deque) | |
| self._lock = threading.Lock() | |
| def check(self, token: str, limit_per_min: int) -> bool: | |
| now = time.time() | |
| window_start = now - 60 | |
| with self._lock: | |
| hits = self._hits[token] | |
| while hits and hits[0] < window_start: | |
| hits.popleft() | |
| if len(hits) >= limit_per_min: | |
| return False | |
| hits.append(now) | |
| return True | |
| limiter = RateLimiter() | |
| # ββ Auth dependency ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _admin_ok(x_admin_token: Optional[str]) -> bool: | |
| expected = os.environ.get("MORPH_ADMIN_TOKEN") | |
| # If no admin token is configured, key management is open on localhost | |
| # only β fine for local dev, must be set in production. | |
| return expected is None or x_admin_token == expected | |
| async def require_api_key(authorization: Optional[str] = Header(None)) -> ApiKey: | |
| if not authorization or not authorization.lower().startswith("bearer "): | |
| raise HTTPException(status_code=401, detail={ | |
| "error": {"message": "Missing bearer token. Send 'Authorization: Bearer frx-...'", | |
| "type": "unauthorized"}}) | |
| token = authorization[7:].strip() | |
| api_key = store.get(token) | |
| if not api_key or api_key.revoked: | |
| raise HTTPException(status_code=401, detail={ | |
| "error": {"message": "Invalid or revoked API key.", "type": "unauthorized"}}) | |
| if not limiter.check(token, api_key.rpm_limit): | |
| raise HTTPException(status_code=429, detail={ | |
| "error": {"message": f"Rate limit exceeded ({api_key.rpm_limit} req/min for plan " | |
| f"'{api_key.plan}').", "type": "rate_limit_exceeded"}}) | |
| return api_key | |
| # ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="Frox AI β Developer API Gateway", version="1.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=os.environ.get("MORPH_CORS_ORIGINS", "*").split(","), | |
| allow_credentials=True, allow_methods=["*"], allow_headers=["*"], | |
| ) | |
| _client: Optional[httpx.AsyncClient] = None | |
| async def _startup(): | |
| global _client | |
| _client = httpx.AsyncClient(base_url=UPSTREAM_URL, timeout=httpx.Timeout(300.0)) | |
| async def _shutdown(): | |
| if _client: | |
| await _client.aclose() | |
| # ββ Key management (admin) βββββββββββββββββββββββββββββββββββββββ | |
| class CreateKeyRequest(BaseModel): | |
| name: str = Field(..., description="Human label for the key") | |
| plan: str = Field("free", description="free | pro | scale") | |
| async def create_key(body: CreateKeyRequest, x_admin_token: Optional[str] = Header(None)): | |
| if not _admin_ok(x_admin_token): | |
| raise HTTPException(status_code=403, detail={"error": {"message": "Admin token required"}}) | |
| key = store.create(body.name, body.plan) | |
| # The full secret is returned exactly once, at creation time. | |
| return {"key": key.key, "name": key.name, "plan": key.plan, "rpm_limit": key.rpm_limit, | |
| "created_at": key.created_at} | |
| async def revoke_key(token: str, x_admin_token: Optional[str] = Header(None)): | |
| if not _admin_ok(x_admin_token): | |
| raise HTTPException(status_code=403, detail={"error": {"message": "Admin token required"}}) | |
| if not store.revoke(token): | |
| raise HTTPException(status_code=404, detail={"error": {"message": "Key not found"}}) | |
| return {"revoked": True, "key": token} | |
| async def usage(api_key: ApiKey = Depends(require_api_key)): | |
| return { | |
| "name": api_key.name, "plan": api_key.plan, "rpm_limit": api_key.rpm_limit, | |
| "total_requests": api_key.total_requests, | |
| "total_prompt_tokens": api_key.total_prompt_tokens, | |
| "total_completion_tokens": api_key.total_completion_tokens, | |
| } | |
| # ββ OpenAI-compatible proxy (authenticated) ββββββββββββββββββββββββββββ | |
| async def proxy_models(api_key: ApiKey = Depends(require_api_key)): | |
| resp = await _client.get("/v1/models") | |
| return JSONResponse(status_code=resp.status_code, content=resp.json()) | |
| async def proxy_chat(request: Request, api_key: ApiKey = Depends(require_api_key)): | |
| body = await request.body() | |
| try: | |
| payload = json.loads(body or b"{}") | |
| except json.JSONDecodeError: | |
| raise HTTPException(status_code=400, detail={"error": {"message": "Invalid JSON body"}}) | |
| is_stream = bool(payload.get("stream")) | |
| if is_stream: | |
| # Pass the SSE stream straight through; usage is recorded as one | |
| # request (token accounting for streamed responses would require | |
| # parsing the final usage chunk β counted as a request here). | |
| async def _relay(): | |
| async with _client.stream("POST", "/v1/chat/completions", content=body, | |
| headers={"Content-Type": "application/json"}) as upstream: | |
| async for chunk in upstream.aiter_raw(): | |
| yield chunk | |
| store.record_usage(api_key.key, 0, 0) | |
| return StreamingResponse(_relay(), media_type="text/event-stream") | |
| resp = await _client.post("/v1/chat/completions", content=body, | |
| headers={"Content-Type": "application/json"}) | |
| data = resp.json() | |
| u = data.get("usage") or {} | |
| store.record_usage(api_key.key, u.get("prompt_tokens", 0), u.get("completion_tokens", 0)) | |
| return JSONResponse(status_code=resp.status_code, content=data) | |
| async def health(): | |
| try: | |
| r = await _client.get("/health") | |
| return {"gateway": "ok", "upstream": r.json()} | |
| except Exception as e: | |
| return JSONResponse(status_code=503, content={"gateway": "ok", "upstream": f"unreachable: {e}"}) | |