File size: 10,659 Bytes
296a506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
"""
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 ─────────────────────────────────────────────

@dataclass
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
    @property
    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


@app.on_event("startup")
async def _startup():
    global _client
    _client = httpx.AsyncClient(base_url=UPSTREAM_URL, timeout=httpx.Timeout(300.0))


@app.on_event("shutdown")
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")


@app.post("/dev/keys")
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}


@app.delete("/dev/keys/{token}")
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}


@app.get("/dev/usage")
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) ────────────────────────────

@app.get("/v1/models")
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())


@app.post("/v1/chat/completions")
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)


@app.get("/health")
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}"})