Add rate limiting (slowapi) to control-plane endpoints: admin, broker, webhook, /v1/models

This commit is contained in:
inference-bot committed 2026-09-14 20:58:13 -06:00
1 parent 505f3d9f48
commit 1312df4131
2 files changed
+32 -1

No files matched your search

+31 -1
View File
@@ -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.
+1
View File
@@ -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