commit 451fb561bc0f6541cc002be0de24d7ad34c44165 Author: inference-bot Date: Fri Sep 4 17:06:27 2026 -0600 Scaffold member portal: OC webhook + Cloudron user mgmt + LiteLLM key injection diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34dcbca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +.git +*.md +.dockerignore diff --git a/CloudronManifest.json b/CloudronManifest.json new file mode 100644 index 0000000..3c3db05 --- /dev/null +++ b/CloudronManifest.json @@ -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" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..09f832c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8472b9d --- /dev/null +++ b/README.md @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..de1660b --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +from .main import app + +__all__ = ["app"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0b2c138 --- /dev/null +++ b/app/main.py @@ -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"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a2834bb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +pydantic==2.9.2