""" 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" # The "members" group in Cloudron (group-based access control). # Members are assigned to this group, which grants access to the chat app. MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "") # --- 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 members group. Verified user shape (from live API): {id, username, email, fallbackEmail, displayName, role, active, groupIds} Roles: "owner", "admin", "user". Group assignment is a SEPARATE call: PUT /api/v1/users/:userId/groups with body {"groupIds": [...]}. """ async with httpx.AsyncClient() as client: # Check if user exists r = await client.get( f"{CLOUDRON_API}/api/v1/users", headers=cloudron_headers(), ) r.raise_for_status() for u in r.json().get("users", []): if u.get("email") == email: return u["id"] # Create user (role "user" = regular member) r = await client.post( f"{CLOUDRON_API}/api/v1/users", headers=cloudron_headers(), json={ "username": email.split("@")[0], "email": email, "displayName": name, "role": "user", "active": True, }, ) r.raise_for_status() return r.json()["id"] async def cloudron_set_group(user_id: str) -> None: """Assign a user to the members group (grants chat app access).""" if not MEMBERS_GROUP_ID: logger.warning("MEMBERS_GROUP_ID not set; skipping group assignment") return async with httpx.AsyncClient() as client: r = await client.put( f"{CLOUDRON_API}/api/v1/users/{user_id}/groups", headers=cloudron_headers(), json={"groupIds": [MEMBERS_GROUP_ID]}, ) r.raise_for_status() 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) # Open Collective webhook events (verified): # - "order.processed" → fires on EVERY payment (incl. monthly recurring) # - "new member" → fires on FIRST contribution only # - payload has "firstPayment" boolean to distinguish new vs recurring # - "collective.transaction.created" is DEPRECATED (being removed) if event_type in ("order.processed", "new.member", "collective.member.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_group(user_id) 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"}