Scaffold member portal: OC webhook + Cloudron user mgmt + LiteLLM key injection
This commit is contained in:
commit
451fb561bc
7 files changed
+353
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "coop.inference.member-portal",
|
||||||
|
"title": "Member Portal",
|
||||||
|
"author": "Inference Cooperative",
|
||||||
|
"description": "Membership middleware: syncs Open Collective members with Cloudron users and LiteLLM keys, and injects per-member keys into inference requests.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"healthCheckPath": "/health",
|
||||||
|
"httpPort": 8000,
|
||||||
|
"addons": {
|
||||||
|
"localstorage": {},
|
||||||
|
"oidc": {
|
||||||
|
"loginRedirectUri": "/",
|
||||||
|
"logoutRedirectUri": "/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manifestVersion": 2,
|
||||||
|
"website": "https://inference.coop",
|
||||||
|
"contactEmail": "info@inference.coop"
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app/code
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy app
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
# Cloudron runs as UID 1000
|
||||||
|
RUN mkdir -p /app/data && chown -R 1000:1000 /app/data /app/code
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Member Portal — membership middleware for the Inference Cooperative
|
||||||
|
|
||||||
|
Reconciles three systems into one membership lifecycle:
|
||||||
|
|
||||||
|
| System | Role | Source of truth for |
|
||||||
|
|--------|------|---------------------|
|
||||||
|
| Open Collective | Billing | Who is a *paying* member |
|
||||||
|
| Cloudron | Identity/SSO | Who can *log in* |
|
||||||
|
| LiteLLM | Inference | Who can *use models*, and how much |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Open Collective (billing)
|
||||||
|
│ webhook
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Member Portal (this app) │
|
||||||
|
│ - OC webhook handler │
|
||||||
|
│ - Cloudron user management │
|
||||||
|
│ - LiteLLM key management │
|
||||||
|
│ - key-injection proxy │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ X-User-Email header (LibreChat forwards identity)
|
||||||
|
│
|
||||||
|
LibreChat ──→ Member Portal ──→ LiteLLM ──→ backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## The three responsibilities
|
||||||
|
|
||||||
|
1. **Webhook handler** (`POST /webhook/opencollective`) — listens for Open
|
||||||
|
Collective membership events and activates/deactivates members.
|
||||||
|
2. **Key injector** (`/v1/*`) — reads the member's email from the
|
||||||
|
`X-User-Email` header, looks up their LiteLLM key, and forwards the
|
||||||
|
request to the gateway with that key.
|
||||||
|
3. **Admin** (`/health`, `/`) — health and status.
|
||||||
|
|
||||||
|
## Configuration (environment variables)
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `CLOUDRON_API` | Cloudron API origin (auto-set by Cloudron) |
|
||||||
|
| `CLOUDRON_TOKEN` | Cloudron API token (Read+Write) |
|
||||||
|
| `LITELLM_BASE` | LiteLLM gateway URL |
|
||||||
|
| `LITELLM_MASTER_KEY` | LiteLLM master key |
|
||||||
|
| `OPENCOLLECTIVE_WEBHOOK_SECRET` | Optional webhook signature secret |
|
||||||
|
|
||||||
|
## Tier budgets
|
||||||
|
|
||||||
|
Governance decision (set in Loomio). Defaults:
|
||||||
|
|
||||||
|
- `free` — $2/month of tokens
|
||||||
|
- `member` — $15/month
|
||||||
|
- `supporter` — $30/month
|
||||||
|
|
||||||
|
## LibreChat wiring
|
||||||
|
|
||||||
|
Point LibreChat's custom endpoint at this portal (not directly at LiteLLM),
|
||||||
|
and add the identity header:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
endpoints:
|
||||||
|
custom:
|
||||||
|
- name: "Inference Cooperative"
|
||||||
|
apiKey: "${LITELLM_KEY}"
|
||||||
|
baseURL: "https://portal.inference.coop/v1"
|
||||||
|
headers:
|
||||||
|
X-User-Email: "{{LIBRECHAT_USER_EMAIL}}"
|
||||||
|
models:
|
||||||
|
default: ["deepseek-v4-flash", "gpt-oss-120b"]
|
||||||
|
fetch: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Scaffold** — the three handlers are stubbed with the correct integration
|
||||||
|
points. Remaining work before production:
|
||||||
|
|
||||||
|
- [ ] Verify Open Collective webhook event names + payload shape
|
||||||
|
- [ ] Verify Cloudron user-creation API payload (role/group assignment)
|
||||||
|
- [ ] Add a persistent store (SQLite/Postgres) for email→key mapping
|
||||||
|
- [ ] Add HMAC signature verification for the OC webhook
|
||||||
|
- [ ] Wire the OIDC addon for admin access
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .main import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
Member Portal — membership middleware for the Inference Cooperative.
|
||||||
|
|
||||||
|
Reconciles three systems:
|
||||||
|
1. Open Collective — who is a paying member (billing)
|
||||||
|
2. Cloudron — who can log in (identity/SSO)
|
||||||
|
3. LiteLLM — who can use the models, and how much (inference)
|
||||||
|
|
||||||
|
Three responsibilities:
|
||||||
|
A. Webhook handler — listen for Open Collective membership events
|
||||||
|
B. Key injector — read the member's email from a header, inject their
|
||||||
|
LiteLLM key, and forward the request to the gateway
|
||||||
|
C. Admin endpoints — health, status, manual reconciliation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Request, Response, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("member-portal")
|
||||||
|
|
||||||
|
app = FastAPI(title="Inference Cooperative Member Portal")
|
||||||
|
|
||||||
|
# --- Configuration (from environment) ---
|
||||||
|
CLOUDRON_API = os.environ.get("CLOUDRON_API_ORIGIN", "https://my.inference.coop")
|
||||||
|
CLOUDRON_TOKEN = os.environ.get("CLOUDRON_TOKEN", "")
|
||||||
|
LITELLM_BASE = os.environ.get("LITELLM_BASE", "https://gateway.inference.coop")
|
||||||
|
LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
|
||||||
|
OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
# Tier → monthly budget (in USD of tokens). Governance decision, set in Loomio.
|
||||||
|
TIER_BUDGETS = {
|
||||||
|
"free": 2.0,
|
||||||
|
"member": 15.0,
|
||||||
|
"supporter": 30.0,
|
||||||
|
}
|
||||||
|
DEFAULT_TIER = "member"
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
def cloudron_headers() -> dict:
|
||||||
|
return {"Authorization": f"Bearer {CLOUDRON_TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def cloudron_create_user(email: str, name: str) -> str:
|
||||||
|
"""Create (or return existing) Cloudron user, assigned to the chat app."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Check if user exists
|
||||||
|
r = await client.get(
|
||||||
|
f"{CLOUDRON_API}/api/v1/users",
|
||||||
|
headers=cloudron_headers(),
|
||||||
|
params={"email": email},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
users = r.json().get("users", [])
|
||||||
|
if users:
|
||||||
|
return users[0]["id"]
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
r = await client.post(
|
||||||
|
f"{CLOUDRON_API}/api/v1/users",
|
||||||
|
headers=cloudron_headers(),
|
||||||
|
json={"email": email, "displayName": name, "role": "user"},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def cloudron_set_active(user_id: str, active: bool) -> None:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{CLOUDRON_API}/api/v1/users/{user_id}",
|
||||||
|
headers=cloudron_headers(),
|
||||||
|
json={"active": active},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def litellm_create_key(email: str, budget: float) -> str:
|
||||||
|
"""Create a LiteLLM virtual key for a member with a budget cap."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{LITELLM_BASE}/key/generate",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
||||||
|
json={
|
||||||
|
"key_alias": f"member:{email}",
|
||||||
|
"max_budget": budget,
|
||||||
|
"budget_duration": "30d",
|
||||||
|
"models": ["deepseek-v4-flash", "gpt-oss-120b"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("key", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def litellm_disable_key(email: str) -> None:
|
||||||
|
"""Disable a member's key (on payment lapse)."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Find the key by alias
|
||||||
|
r = await client.get(
|
||||||
|
f"{LITELLM_BASE}/key/list",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
for k in r.json().get("keys", []):
|
||||||
|
if k.get("key_alias") == f"member:{email}":
|
||||||
|
await client.post(
|
||||||
|
f"{LITELLM_BASE}/key/delete",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
||||||
|
json={"keys": [k["token"]]},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# --- A. Open Collective webhook ---
|
||||||
|
|
||||||
|
@app.post("/webhook/opencollective")
|
||||||
|
async def opencollective_webhook(request: Request):
|
||||||
|
"""Handle Open Collective membership events."""
|
||||||
|
payload = await request.json()
|
||||||
|
|
||||||
|
# Verify webhook secret if configured
|
||||||
|
if OPENCOLLECTIVE_SECRET:
|
||||||
|
sig = request.headers.get("x-oc-signature", "")
|
||||||
|
# TODO: verify HMAC signature
|
||||||
|
if not sig:
|
||||||
|
raise HTTPException(401, "Missing signature")
|
||||||
|
|
||||||
|
event_type = payload.get("type", "")
|
||||||
|
data = payload.get("data", {})
|
||||||
|
member = data.get("member", {}) or data.get("fromCollective", {})
|
||||||
|
email = member.get("email") or data.get("email")
|
||||||
|
name = member.get("name", "Member")
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
logger.warning("Webhook without email: %s", event_type)
|
||||||
|
return JSONResponse({"status": "ignored", "reason": "no email"})
|
||||||
|
|
||||||
|
logger.info("Open Collective event: %s for %s", event_type, email)
|
||||||
|
|
||||||
|
if event_type in ("collective.member.created", "collective.transaction.created"):
|
||||||
|
# New or renewed member → ensure active
|
||||||
|
tier = (data.get("tier") or {}).get("slug", DEFAULT_TIER)
|
||||||
|
budget = TIER_BUDGETS.get(tier, TIER_BUDGETS[DEFAULT_TIER])
|
||||||
|
user_id = await cloudron_create_user(email, name)
|
||||||
|
await cloudron_set_active(user_id, True)
|
||||||
|
await litellm_create_key(email, budget)
|
||||||
|
return JSONResponse({"status": "activated", "user_id": user_id, "budget": budget})
|
||||||
|
|
||||||
|
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
||||||
|
# Lapsed member → deactivate
|
||||||
|
await litellm_disable_key(email)
|
||||||
|
return JSONResponse({"status": "deactivated"})
|
||||||
|
|
||||||
|
return JSONResponse({"status": "ignored", "type": event_type})
|
||||||
|
|
||||||
|
|
||||||
|
# --- B. Key injector (proxy) ---
|
||||||
|
|
||||||
|
@app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
|
async def inject_key(request: Request, path: str):
|
||||||
|
"""Read the member's email from a header, inject their key, forward to LiteLLM."""
|
||||||
|
email = request.headers.get("x-user-email", "")
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(401, "No member identity (x-user-email header)")
|
||||||
|
|
||||||
|
# Look up the member's key (in production: from a store keyed by email)
|
||||||
|
# For MVP: derive deterministically or look up via LiteLLM
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{LITELLM_BASE}/key/list",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
member_key = None
|
||||||
|
for k in r.json().get("keys", []):
|
||||||
|
if k.get("key_alias") == f"member:{email}":
|
||||||
|
member_key = k.get("token")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not member_key:
|
||||||
|
raise HTTPException(403, "No active membership key")
|
||||||
|
|
||||||
|
# Forward the request to LiteLLM with the member's key
|
||||||
|
body = await request.body()
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers["authorization"] = f"Bearer {member_key}"
|
||||||
|
headers.pop("host", None)
|
||||||
|
headers.pop("content-length", None)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
upstream = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=f"{LITELLM_BASE}/v1/{path}",
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=upstream.content,
|
||||||
|
status_code=upstream.status_code,
|
||||||
|
headers={"content-type": upstream.headers.get("content-type", "application/json")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- C. Admin / health ---
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
httpx==0.27.2
|
||||||
|
pydantic==2.9.2
|
||||||
Reference in new issue
Block a user