Add rate limiting (slowapi) to control-plane endpoints: admin, broker, webhook, /v1/models
This commit is contained in:
1 parent
505f3d9f48
commit
1312df4131
2 files changed
+32
-1
No files matched your search
+31
-1
@@ -27,12 +27,31 @@ import httpx
|
|||||||
import jwt
|
import jwt
|
||||||
from fastapi import FastAPI, Request, Response, HTTPException
|
from fastapi import FastAPI, Request, Response, HTTPException
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("member-portal")
|
logger = logging.getLogger("member-portal")
|
||||||
|
|
||||||
app = FastAPI(title="Inference Cooperative Member Portal")
|
app = FastAPI(title="Inference Cooperative Member Portal")
|
||||||
|
|
||||||
|
# --- Rate limiting (control plane only, not the member inference path) ---
|
||||||
|
# The data plane (chat completions via /v1/*) is capped by each member's
|
||||||
|
# LiteLLM budget, NOT by rate — a member streaming a long conversation must
|
||||||
|
# never be throttled. Rate limiting here covers the control-plane surfaces:
|
||||||
|
# admin endpoints, the broker, the webhook, and the unauthenticated model list.
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RateLimitExceeded)
|
||||||
|
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "Too many requests. Slow down."},
|
||||||
|
)
|
||||||
|
|
||||||
# --- Configuration (from environment) ---
|
# --- Configuration (from environment) ---
|
||||||
CLOUDRON_API = os.environ.get("CLOUDRON_API_ORIGIN", "https://my.inference.coop")
|
CLOUDRON_API = os.environ.get("CLOUDRON_API_ORIGIN", "https://my.inference.coop")
|
||||||
CLOUDRON_TOKEN = os.environ.get("CLOUDRON_TOKEN", "")
|
CLOUDRON_TOKEN = os.environ.get("CLOUDRON_TOKEN", "")
|
||||||
@@ -780,6 +799,7 @@ async def broker_revoke_api_key(email: str, name: str) -> None:
|
|||||||
# --- A. Open Collective webhook ---
|
# --- A. Open Collective webhook ---
|
||||||
|
|
||||||
@app.post("/webhook/opencollective/{token}")
|
@app.post("/webhook/opencollective/{token}")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
async def opencollective_webhook(request: Request, token: str):
|
async def opencollective_webhook(request: Request, token: str):
|
||||||
"""Handle Open Collective membership events.
|
"""Handle Open Collective membership events.
|
||||||
|
|
||||||
@@ -854,7 +874,8 @@ async def opencollective_webhook(request: Request, token: str):
|
|||||||
# --- B. Key injector (proxy) ---
|
# --- B. Key injector (proxy) ---
|
||||||
|
|
||||||
@app.get("/v1/models")
|
@app.get("/v1/models")
|
||||||
async def list_models():
|
@limiter.limit("60/minute")
|
||||||
|
async def list_models(request: Request):
|
||||||
"""List models (same for everyone — no per-user auth needed).
|
"""List models (same for everyone — no per-user auth needed).
|
||||||
|
|
||||||
Annotates each model with OpenWebUI metadata so web search is enabled by
|
Annotates each model with OpenWebUI metadata so web search is enabled by
|
||||||
@@ -971,6 +992,7 @@ async def health():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/admin/sync-loomio")
|
@app.post("/admin/sync-loomio")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
async def admin_sync_loomio(request: Request):
|
async def admin_sync_loomio(request: Request):
|
||||||
"""Manually trigger a Loomio membership sync.
|
"""Manually trigger a Loomio membership sync.
|
||||||
|
|
||||||
@@ -985,6 +1007,7 @@ async def admin_sync_loomio(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/admin/provision")
|
@app.post("/admin/provision")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
async def admin_provision(request: Request):
|
async def admin_provision(request: Request):
|
||||||
"""Manually provision a member by email (full pipeline).
|
"""Manually provision a member by email (full pipeline).
|
||||||
|
|
||||||
@@ -1005,6 +1028,7 @@ async def admin_provision(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/admin/set-balance")
|
@app.post("/admin/set-balance")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
async def admin_set_balance(request: Request):
|
async def admin_set_balance(request: Request):
|
||||||
"""Set (or add to) a member's credit balance and sync it to LiteLLM.
|
"""Set (or add to) a member's credit balance and sync it to LiteLLM.
|
||||||
|
|
||||||
@@ -1039,6 +1063,7 @@ async def admin_set_balance(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/admin/overview")
|
@app.get("/admin/overview")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
async def admin_overview(request: Request):
|
async def admin_overview(request: Request):
|
||||||
"""Return a co-op-wide overview for the admin dashboard.
|
"""Return a co-op-wide overview for the admin dashboard.
|
||||||
|
|
||||||
@@ -1184,12 +1209,14 @@ def resolve_member_email(identity: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/broker/keys")
|
@app.get("/broker/keys")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
async def broker_list(request: Request):
|
async def broker_list(request: Request):
|
||||||
email = _verify_broker_secret(request)
|
email = _verify_broker_secret(request)
|
||||||
return {"keys": await broker_list_api_keys(email)}
|
return {"keys": await broker_list_api_keys(email)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/broker/keys")
|
@app.post("/broker/keys")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
async def broker_create(request: Request):
|
async def broker_create(request: Request):
|
||||||
email = _verify_broker_secret(request)
|
email = _verify_broker_secret(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -1199,6 +1226,7 @@ async def broker_create(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/broker/keys/{name}")
|
@app.delete("/broker/keys/{name}")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
async def broker_revoke(name: str, request: Request):
|
async def broker_revoke(name: str, request: Request):
|
||||||
email = _verify_broker_secret(request)
|
email = _verify_broker_secret(request)
|
||||||
await broker_revoke_api_key(email, name)
|
await broker_revoke_api_key(email, name)
|
||||||
@@ -1276,6 +1304,7 @@ async def broker_get_usage(email: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/broker/usage")
|
@app.get("/broker/usage")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
async def broker_usage(request: Request):
|
async def broker_usage(request: Request):
|
||||||
email = _verify_broker_secret(request)
|
email = _verify_broker_secret(request)
|
||||||
return await broker_get_usage(email)
|
return await broker_get_usage(email)
|
||||||
@@ -1346,6 +1375,7 @@ async def reconcile_memberships() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/admin/sweep")
|
@app.post("/admin/sweep")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
async def admin_sweep(request: Request):
|
async def admin_sweep(request: Request):
|
||||||
"""Manually trigger a full membership reconciliation.
|
"""Manually trigger a full membership reconciliation.
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.30.6
|
|||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
pydantic==2.9.2
|
pydantic==2.9.2
|
||||||
PyJWT==2.9.0
|
PyJWT==2.9.0
|
||||||
|
slowapi==0.1.9
|
||||||
Reference in new issue
Block a user