From 1312df4131689447648ba8bebc658d7b9820a050 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Mon, 14 Sep 2026 20:58:13 -0600 Subject: [PATCH] Add rate limiting (slowapi) to control-plane endpoints: admin, broker, webhook, /v1/models --- app/main.py | 32 +++++++++++++++++++++++++++++++- requirements.txt | 1 + 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 9097503..0d843bc 100644 --- a/app/main.py +++ b/app/main.py @@ -27,12 +27,31 @@ import httpx import jwt from fastapi import FastAPI, Request, Response, HTTPException 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) logger = logging.getLogger("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) --- CLOUDRON_API = os.environ.get("CLOUDRON_API_ORIGIN", "https://my.inference.coop") 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 --- @app.post("/webhook/opencollective/{token}") +@limiter.limit("30/minute") async def opencollective_webhook(request: Request, token: str): """Handle Open Collective membership events. @@ -854,7 +874,8 @@ async def opencollective_webhook(request: Request, token: str): # --- B. Key injector (proxy) --- @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). Annotates each model with OpenWebUI metadata so web search is enabled by @@ -971,6 +992,7 @@ async def health(): @app.post("/admin/sync-loomio") +@limiter.limit("20/minute") async def admin_sync_loomio(request: Request): """Manually trigger a Loomio membership sync. @@ -985,6 +1007,7 @@ async def admin_sync_loomio(request: Request): @app.post("/admin/provision") +@limiter.limit("20/minute") async def admin_provision(request: Request): """Manually provision a member by email (full pipeline). @@ -1005,6 +1028,7 @@ async def admin_provision(request: Request): @app.post("/admin/set-balance") +@limiter.limit("20/minute") async def admin_set_balance(request: Request): """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") +@limiter.limit("30/minute") async def admin_overview(request: Request): """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") +@limiter.limit("30/minute") async def broker_list(request: Request): email = _verify_broker_secret(request) return {"keys": await broker_list_api_keys(email)} @app.post("/broker/keys") +@limiter.limit("30/minute") async def broker_create(request: Request): email = _verify_broker_secret(request) body = await request.json() @@ -1199,6 +1226,7 @@ async def broker_create(request: Request): @app.delete("/broker/keys/{name}") +@limiter.limit("30/minute") async def broker_revoke(name: str, request: Request): email = _verify_broker_secret(request) await broker_revoke_api_key(email, name) @@ -1276,6 +1304,7 @@ async def broker_get_usage(email: str) -> dict: @app.get("/broker/usage") +@limiter.limit("60/minute") async def broker_usage(request: Request): email = _verify_broker_secret(request) return await broker_get_usage(email) @@ -1346,6 +1375,7 @@ async def reconcile_memberships() -> dict: @app.post("/admin/sweep") +@limiter.limit("20/minute") async def admin_sweep(request: Request): """Manually trigger a full membership reconciliation. diff --git a/requirements.txt b/requirements.txt index e0ad96a..4f514b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.30.6 httpx==0.27.2 pydantic==2.9.2 PyJWT==2.9.0 +slowapi==0.1.9