Files
member-portal/app/main.py
T

1021 lines
41 KiB
Python

"""
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 sqlite3
import secrets
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import httpx
import jwt
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", "")
# Shared secret that the chat frontend uses to authenticate requests to the
# portal. LibreChat sends it as an X-Portal-Secret header; OpenWebUI signs the
# user's email as a JWT (see FORWARD_USER_INFO_HEADER_JWT_SECRET below).
PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "")
# OpenWebUI can sign the forwarded user identity as a JWT using this shared
# secret. When set, the portal verifies the JWT (HS256) to confirm the email
# genuinely came from OpenWebUI (behind Cloudron SSO) rather than a spoofed
# header. This is stronger than the plain X-Portal-Secret header because the
# email itself is tamper-proof.
OWUI_JWT_SECRET = os.environ.get("OWUI_JWT_SECRET", "")
# Secret token required in the Open Collective webhook URL path. Open
# Collective's generic webhooks are not HMAC-signed, so a secret in the URL
# is the standard way to authenticate them.
WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "")
# Open Collective OAuth app credentials (for the "connect your account" flow,
# which is how we obtain a member's email — the webhook strips it for privacy).
OC_OAUTH_CLIENT_ID = os.environ.get("OC_OAUTH_CLIENT_ID", "")
OC_OAUTH_CLIENT_SECRET = os.environ.get("OC_OAUTH_CLIENT_SECRET", "")
OC_OAUTH_REDIRECT_URI = os.environ.get(
"OC_OAUTH_REDIRECT_URI", "https://portal.inference.coop/oauth/callback"
)
OC_AUTHORIZE_URL = "https://opencollective.com/oauth/authorize"
OC_TOKEN_URL = "https://opencollective.com/oauth/token"
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative")
# Personal token for the "Inference Co-op Bot" account, which is an admin of
# the collective. Authenticated as an admin, the GraphQL API exposes member
# emails (which are hidden from anonymous access). We use this to match a
# member by email — the stable identifier that works even for guest
# contributors (who have no Open Collective account and thus no usable slug).
OC_PERSONAL_TOKEN = os.environ.get("OC_PERSONAL_TOKEN", "")
# Monthly credit budget (in USD of tokens) for all members.
# Single sliding-scale tier: everyone gets the same $15/month in credits,
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0"))
# 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", "")
# The "inactive" group in Cloudron. Lapsed members are moved here (instead of
# being deleted) so they lose access but can reclaim their data on reactivation.
INACTIVE_GROUP_ID = os.environ.get("INACTIVE_GROUP_ID", "")
# Loomio integration (forum.inference.coop). The portal reconciles the Loomio
# group to the current active-member list via the User API (/api/b2).
LOOMIO_BASE = os.environ.get("LOOMIO_BASE", "https://forum.inference.coop")
LOOMIO_API_KEY = os.environ.get("LOOMIO_API_KEY", "")
LOOMIO_GROUP_ID = os.environ.get("LOOMIO_GROUP_ID", "")
# Operator accounts that must always remain in the Loomio group (never removed
# by the remove_absent reconciliation), comma-separated.
LOOMIO_ALWAYS_KEEP = [
e.strip()
for e in os.environ.get("LOOMIO_ALWAYS_KEEP", "info@inference.coop,bot@inference.coop").split(",")
if e.strip()
]
# Persistent store (SQLite) for email → LiteLLM key token mapping.
# Lives in /app/data (Cloudron localstorage addon persists this).
DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db")
# --- Email (Cloudron sendmail addon) ---
# The sendmail addon exports SMTP credentials as CLOUDRON_MAIL_* env vars.
SMTP_SERVER = os.environ.get("CLOUDRON_MAIL_SMTP_SERVER", "")
SMTP_PORT = int(os.environ.get("CLOUDRON_MAIL_SMTP_PORT", "2525"))
SMTP_USERNAME = os.environ.get("CLOUDRON_MAIL_SMTP_USERNAME", "")
SMTP_PASSWORD = os.environ.get("CLOUDRON_MAIL_SMTP_PASSWORD", "")
MAIL_FROM = os.environ.get("CLOUDRON_MAIL_FROM", "info@inference.coop")
MAIL_FROM_NAME = os.environ.get("CLOUDRON_MAIL_FROM_DISPLAY_NAME", "Inference Cooperative")
# Public base URL for the portal (used in email links).
PORTAL_BASE = os.environ.get("PORTAL_BASE", "https://portal.inference.coop")
def _verify_portal_secret(request: Request) -> None:
"""Reject requests that didn't come through LibreChat (shared secret)."""
if not PORTAL_SECRET:
# If no secret is configured, refuse to inject keys (fail closed).
raise HTTPException(503, "Portal secret not configured")
provided = request.headers.get("x-portal-secret", "")
if not secrets.compare_digest(provided, PORTAL_SECRET):
raise HTTPException(401, "Invalid portal secret")
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.execute(
"CREATE TABLE IF NOT EXISTS members ("
"email TEXT PRIMARY KEY, "
"key_token TEXT, "
"cloudron_user_id TEXT, "
"slug TEXT, "
"active INTEGER DEFAULT 1"
")"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS pending_members ("
"slug TEXT PRIMARY KEY, "
"name TEXT, "
"created_at TEXT DEFAULT (datetime('now'))"
")"
)
# Migration: add slug column if the members table predates it.
cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()]
if "slug" not in cols:
conn.execute("ALTER TABLE members ADD COLUMN slug TEXT")
return conn
def store_pending_member(slug: str, name: str) -> None:
conn = get_db()
conn.execute(
"INSERT INTO pending_members (slug, name) VALUES (?, ?) "
"ON CONFLICT(slug) DO UPDATE SET name=excluded.name",
(slug, name),
)
conn.commit()
conn.close()
def is_pending_member(slug: str) -> bool:
"""True if this slug has a pending membership (i.e. the webhook saw a
contribution from them). Used to gate the OAuth flow so only paying
members can provision an account."""
if not slug:
return False
conn = get_db()
row = conn.execute(
"SELECT 1 FROM pending_members WHERE slug = ?", (slug,)
).fetchone()
conn.close()
return row is not None
async def fetch_member_emails() -> dict[str, str]:
"""Return a map of email -> role for active financial contributors.
Uses the bot's personal token (admin) so the GraphQL API exposes member
emails, which are hidden from anonymous access. This is the authoritative
source of truth for "who is a member" — and it works for guest
contributors (who have no OC account) because their email is still in the
list.
"""
if not OC_PERSONAL_TOKEN:
logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch member emails")
return {}
query = (
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email } } } } }'
% OC_COLLECTIVE_SLUG
)
async with httpx.AsyncClient() as client:
r = await client.post(
OC_GRAPHQL_URL,
headers={"Personal-Token": OC_PERSONAL_TOKEN},
json={"query": query},
)
if r.status_code != 200:
logger.warning("OC member fetch failed: %s", r.status_code)
return {}
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
result: dict[str, str] = {}
for n in nodes:
role = n.get("role", "")
acct = n.get("account", {}) or {}
email = (acct.get("email") or "").strip().lower()
if email and role in ("BACKER", "ADMIN"):
result[email] = role
return result
async def is_active_member(email: str) -> bool:
"""Check whether this email is an active financial contributor.
Matches on email (not slug) so guest contributors — who have no OC account
and thus no usable slug — are still recognized. The email comes from the
OAuth `me` query (with the user's consent); we match it against the
admin-visible member list.
"""
if not email:
return False
members = await fetch_member_emails()
return email.strip().lower() in members
async def fetch_members() -> list[dict]:
"""Return the full active-member list: email, name, slug, role.
Uses the bot's personal token (admin) so emails are exposed. This is the
authoritative source of truth for reconciliation (provisioning missing
members, deactivating lapsed ones).
"""
if not OC_PERSONAL_TOKEN:
logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch members")
return []
query = (
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email name slug } } } } }'
% OC_COLLECTIVE_SLUG
)
async with httpx.AsyncClient() as client:
r = await client.post(
OC_GRAPHQL_URL,
headers={"Personal-Token": OC_PERSONAL_TOKEN},
json={"query": query},
)
if r.status_code != 200:
logger.warning("OC member fetch failed: %s", r.status_code)
return []
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
result: list[dict] = []
for n in nodes:
role = n.get("role", "")
acct = n.get("account", {}) or {}
email = (acct.get("email") or "").strip().lower()
if email and role in ("BACKER", "ADMIN"):
result.append({
"email": email,
"name": acct.get("name") or email.split("@")[0],
"slug": acct.get("slug") or "",
"role": role,
})
return result
def send_email(to: str, subject: str, text_body: str, html_body: str | None = None) -> bool:
"""Send an email via Cloudron's sendmail addon (SMTP).
Returns True on success, False on failure (logged, not raised — email is
best-effort and must never break the provisioning flow).
"""
if not SMTP_SERVER:
logger.warning("SMTP not configured; skipping email to %s", to)
return False
try:
msg = MIMEMultipart("alternative")
msg["From"] = f"{MAIL_FROM_NAME} <{MAIL_FROM}>"
msg["To"] = to
msg["Subject"] = subject
msg.attach(MIMEText(text_body, "plain", "utf-8"))
if html_body:
msg.attach(MIMEText(html_body, "html", "utf-8"))
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=15) as s:
if SMTP_USERNAME:
s.login(SMTP_USERNAME, SMTP_PASSWORD)
s.sendmail(MAIL_FROM, [to], msg.as_string())
logger.info("Sent email to %s: %s", to, subject)
return True
except Exception as e:
logger.warning("Failed to send email to %s: %s", to, e)
return False
def welcome_email(name: str) -> tuple[str, str, str]:
"""Build the welcome email (subject, text, html) for a new member."""
join_url = f"{PORTAL_BASE}/join"
subject = "Welcome to the Inference Cooperative — finish your setup"
text = (
f"Hi {name},\n\n"
"Thanks for joining the Inference Cooperative!\n\n"
"To finish setting up your account and start using private AI chat, "
"visit the link below and connect your Open Collective account:\n\n"
f"{join_url}\n\n"
"This takes about a minute. You'll then get an account-setup email "
"from our platform.\n\n"
"Questions? Reply to this email or write to info@inference.coop.\n\n"
"— The Inference Cooperative\n"
)
html = (
f"<p>Hi {name},</p>"
"<p>Thanks for joining the <strong>Inference Cooperative</strong>!</p>"
"<p>To finish setting up your account and start using private AI chat, "
"click the button below and connect your Open Collective account:</p>"
f'<p><a href="{join_url}" style="display:inline-block;padding:12px 24px;'
'background:#5b8c5a;color:#fff;text-decoration:none;border-radius:8px;">'
"Finish setup</a></p>"
"<p>This takes about a minute. You'll then get an account-setup email "
"from our platform.</p>"
'<p>Questions? Reply to this email or write to '
'<a href="mailto:info@inference.coop">info@inference.coop</a>.</p>'
"<p>— The Inference Cooperative</p>"
)
return subject, text, html
async def get_active_member_emails() -> list[str]:
"""Return the list of active member emails from the portal's own database.
We use the local `members` table (populated during OAuth, which captures the
member's email) rather than Open Collective's GraphQL, because OC returns
`email: null` for privacy — the email field is only exposed via OAuth with
the user's consent. The local table is the authoritative source of member
emails.
"""
conn = get_db()
rows = conn.execute(
"SELECT email FROM members WHERE active = 1"
).fetchall()
conn.close()
return [r[0] for r in rows]
async def sync_loomio_memberships() -> None:
"""Reconcile the Loomio group to the current active-member list.
Uses POST /api/b2/memberships with remove_absent=1, which adds new members
and removes anyone not in the list. Operator accounts (LOOMIO_ALWAYS_KEEP)
are always included so they are never removed.
"""
if not LOOMIO_API_KEY or not LOOMIO_GROUP_ID:
logger.warning("Loomio not configured; skipping membership sync")
return
emails = await get_active_member_emails()
# Always keep operator accounts in the group.
for keep in LOOMIO_ALWAYS_KEEP:
if keep not in emails:
emails.append(keep)
async with httpx.AsyncClient() as client:
r = await client.post(
f"{LOOMIO_BASE}/api/b2/memberships",
headers={"Authorization": f"Bearer {LOOMIO_API_KEY}"},
json={"group_id": int(LOOMIO_GROUP_ID), "emails": emails, "remove_absent": 1},
)
if r.status_code != 200:
logger.warning("Loomio membership sync failed: %s %s", r.status_code, r.text[:200])
return
result = r.json()
logger.info(
"Loomio sync: added=%s removed=%s",
result.get("added_emails", []),
result.get("removed_emails", []),
)
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
conn = get_db()
conn.execute(
"INSERT INTO members (email, key_token, cloudron_user_id, slug, active) "
"VALUES (?, ?, ?, ?, 1) "
"ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, "
"cloudron_user_id=excluded.cloudron_user_id, slug=excluded.slug, active=1",
(email, key_token, cloudron_user_id, slug),
)
conn.commit()
conn.close()
def get_member_key(email: str) -> str | None:
conn = get_db()
row = conn.execute(
"SELECT key_token FROM members WHERE email = ? AND active = 1", (email,)
).fetchone()
conn.close()
return row[0] if row else None
def get_member_by_slug(slug: str) -> dict | None:
"""Look up a member (email, key_token, cloudron_user_id) by their OC slug."""
conn = get_db()
row = conn.execute(
"SELECT email, key_token, cloudron_user_id FROM members WHERE slug = ?", (slug,)
).fetchone()
conn.close()
if not row:
return None
return {"email": row[0], "key_token": row[1], "cloudron_user_id": row[2]}
def deactivate_member(email: str) -> str | None:
"""Mark a member inactive and return their key token (for deletion)."""
conn = get_db()
row = conn.execute(
"SELECT key_token FROM members WHERE email = ?", (email,)
).fetchone()
conn.execute("UPDATE members SET active = 0 WHERE email = ?", (email,))
conn.commit()
conn.close()
return row[0] if row else None
# --- 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). Only email is required;
# Cloudron lets the user choose their own username during account setup.
r = await client.post(
f"{CLOUDRON_API}/api/v1/users",
headers=cloudron_headers(),
json={
"email": email,
"displayName": name,
"role": "user",
"active": True,
},
)
r.raise_for_status()
return r.json()["id"]
async def cloudron_set_group(user_id: str, group_id: str | None = None) -> None:
"""Assign a user to a group (defaults to the members group)."""
gid = group_id or MEMBERS_GROUP_ID
if not gid:
logger.warning("No group id; 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": [gid]},
)
r.raise_for_status()
async def cloudron_set_active(user_id: str, active: bool) -> None:
async with httpx.AsyncClient() as client:
r = await client.put(
f"{CLOUDRON_API}/api/v1/users/{user_id}/active",
headers=cloudron_headers(),
json={"active": active},
)
r.raise_for_status()
async def cloudron_send_invite(user_id: str, email: str) -> None:
"""Send the account-setup invite email to the member."""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{CLOUDRON_API}/api/v1/users/{user_id}/send_invite_email",
headers=cloudron_headers(),
json={"email": email},
)
r.raise_for_status()
async def litellm_find_key_by_alias(alias: str) -> str | None:
"""Find an existing key's token by its alias (key/list returns tokens)."""
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()
tokens = r.json().get("keys", [])
for token in tokens:
info = await client.get(
f"{LITELLM_BASE}/key/info",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
params={"key": token},
)
if info.status_code == 200:
data = info.json().get("info", {})
if data.get("key_alias") == alias:
return token
return None
async def litellm_create_key(email: str, budget: float) -> str:
"""Create a LiteLLM virtual key for a member with a budget cap.
Idempotent: if a key with this alias already exists, return its token.
"""
alias = f"member:{email}"
# If the key already exists (e.g. from a prior partial attempt), reuse it.
existing = await litellm_find_key_by_alias(alias)
if existing:
logger.info("Reusing existing LiteLLM key for %s", email)
return existing
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": alias,
"max_budget": budget,
"budget_duration": "30d",
"models": ["deepseek-v4-flash", "gpt-oss-120b", "glm-5-3-flash"],
},
)
r.raise_for_status()
return r.json().get("key", "")
async def litellm_disable_key(key_token: str) -> None:
"""Delete a member's LiteLLM key (on payment lapse)."""
if not key_token:
return
async with httpx.AsyncClient() as client:
await client.post(
f"{LITELLM_BASE}/key/delete",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
json={"keys": [key_token]},
)
# --- A. Open Collective webhook ---
@app.post("/webhook/opencollective/{token}")
async def opencollective_webhook(request: Request, token: str):
"""Handle Open Collective membership events.
Authenticated by a secret token in the URL path (Open Collective's
generic webhooks are not HMAC-signed, so a secret URL is the standard
way to authenticate them).
"""
if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):
raise HTTPException(401, "Invalid webhook token")
payload = await request.json()
event_type = payload.get("type", "")
data = payload.get("data", {})
# The webhook does NOT include email (Open Collective strips it for
# privacy). We get name + slug, store a pending member, and obtain the
# email later via the OAuth "connect your account" flow.
#
# Slug/name live in different places depending on the event type:
# - order.processed / transaction.created → data.fromCollective.{slug,name}
# - collective.member.created → data.member.memberCollective.{slug,name}
from_collective = data.get("fromCollective", {})
member_collective = (data.get("member", {}) or {}).get("memberCollective", {})
slug = from_collective.get("slug") or member_collective.get("slug", "")
name = from_collective.get("name") or member_collective.get("name", "Member")
logger.info("Open Collective event: %s (name=%s, slug=%s)", event_type, name, slug)
# 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 → store as pending; they complete via OAuth.
if slug:
store_pending_member(slug, name)
# Send the welcome email immediately so the member gets feedback that
# their contribution was received and knows the next step (/join).
# The webhook strips email, so look it up by slug via the admin token.
if slug:
members = await fetch_members()
for m in members:
if m["slug"] == slug:
subject, text, html = welcome_email(m["name"])
send_email(m["email"], subject, text, html)
break
return JSONResponse({"status": "pending", "name": name, "slug": slug})
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
# Lapsed member → move to the inactive group (lose access, keep data).
# The webhook gives us the slug; we map it to the member's email/user.
if slug:
member = get_member_by_slug(slug)
if member:
# Move to inactive group (revokes chat access, keeps account + data)
if INACTIVE_GROUP_ID and member.get("cloudron_user_id"):
await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID)
# Mark inactive in our DB (key injector will refuse requests)
deactivate_member(member["email"])
await sync_loomio_memberships()
logger.info("Deactivated member %s (slug=%s)", member["email"], slug)
return JSONResponse({"status": "deactivated", "email": member["email"]})
return JSONResponse({"status": "deactivated", "note": "no matching member"})
return JSONResponse({"status": "ignored", "type": event_type})
# --- B. Key injector (proxy) ---
@app.get("/v1/models")
async def list_models():
"""List models (same for everyone — no per-user auth needed).
Annotates each model with OpenWebUI metadata so web search is enabled by
default (defaultFeatureIds includes 'web_search'). Without this, OpenWebUI
leaves web search off for these models and members must toggle it manually.
"""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{LITELLM_BASE}/v1/models",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
)
if r.status_code != 200:
return Response(
content=r.content,
status_code=r.status_code,
headers={"content-type": r.headers.get("content-type", "application/json")},
)
payload = r.json()
for model in payload.get("data", []):
model.setdefault("info", {}).setdefault("meta", {})
model["info"]["meta"].setdefault("capabilities", {})["web_search"] = True
model["info"]["meta"].setdefault("defaultFeatureIds", [])
if "web_search" not in model["info"]["meta"]["defaultFeatureIds"]:
model["info"]["meta"]["defaultFeatureIds"].append("web_search")
return JSONResponse(content=payload)
@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."""
# Verify the request came through the chat frontend, so a direct caller
# can't spoof the user-email header and use another member's key.
#
# Two supported auth paths:
# 1. OpenWebUI signs the user identity as a JWT (X-OpenWebUI-User-Jwt)
# with a shared secret — the email is tamper-proof, so no separate
# secret header is needed.
# 2. LibreChat sends a plain X-User-Email header plus an X-Portal-Secret
# shared-secret header.
email = ""
jwt_header = request.headers.get("x-openwebui-user-jwt", "")
if jwt_header and OWUI_JWT_SECRET:
try:
claims = jwt.decode(jwt_header, OWUI_JWT_SECRET, algorithms=["HS256"])
email = claims.get("email", "")
except jwt.PyJWTError:
raise HTTPException(401, "Invalid user identity token")
else:
_verify_portal_secret(request)
email = (
request.headers.get("x-user-email", "")
or request.headers.get("x-openwebui-user-email", "")
)
if not email:
raise HTTPException(401, "No member identity (user-email header)")
# Look up the member's key from the persistent store
member_key = get_member_key(email)
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. OAuth "connect your account" flow ---
@app.get("/join")
async def join_page():
"""The 'finish your setup' page — where a new member connects their
Open Collective account so we can obtain their email (with consent)."""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Finish your setup — Inference Cooperative</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
.btn { display: inline-block; background: #5b8c5a; color: #fff; text-decoration: none; padding: 14px 32px; border-radius: 10px; font-weight: 600; }
.btn:hover { opacity: 0.9; }
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
.note a { color: #6b7a62; }
</style>
</head>
<body>
<div class="card">
<h1>Finish your setup</h1>
<p>Thanks for joining the Inference Cooperative! To set up your account, connect your Open Collective account so we can verify your membership.</p>
<a class="btn" href="/oauth/start">Connect Open Collective</a>
<p class="note">You'll receive an account-setup email shortly after connecting.<br>Didn't get it? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
<p class="note">Contributed as a guest? <a href="https://opencollective.com/signin">Create an Open Collective account</a> using the same email you used to contribute, then return here and connect it.</p>
</div>
</body>
</html>"""
return Response(content=html, media_type="text/html")
@app.get("/oauth/start")
async def oauth_start():
"""Redirect the user to Open Collective's consent screen."""
state = secrets.token_urlsafe(16)
params = {
"client_id": OC_OAUTH_CLIENT_ID,
"response_type": "code",
"redirect_uri": OC_OAUTH_REDIRECT_URI,
"scope": "email",
"state": state,
}
qs = "&".join(f"{k}={v}" for k, v in params.items())
return Response(
status_code=302,
headers={"Location": f"{OC_AUTHORIZE_URL}?{qs}"},
)
@app.get("/oauth/callback")
async def oauth_callback(request: Request):
"""Exchange the OAuth code for a token, fetch the email, and provision."""
code = request.query_params.get("code", "")
if not code:
raise HTTPException(400, "Missing code")
# Exchange code for access token
async with httpx.AsyncClient() as client:
r = await client.post(
OC_TOKEN_URL,
data={
"grant_type": "authorization_code",
"client_id": OC_OAUTH_CLIENT_ID,
"client_secret": OC_OAUTH_CLIENT_SECRET,
"code": code,
"redirect_uri": OC_OAUTH_REDIRECT_URI,
},
)
r.raise_for_status()
token = r.json().get("access_token", "")
if not token:
raise HTTPException(500, "No access token returned")
# Fetch the user's email + slug (slug lets us map webhook events back)
r = await client.post(
OC_GRAPHQL_URL,
headers={"Authorization": f"Bearer {token}"},
json={"query": "{ me { id name email slug } }"},
)
r.raise_for_status()
me = r.json().get("data", {}).get("me", {})
email = me.get("email", "")
name = me.get("name", "Member")
slug = me.get("slug", "")
if not email:
raise HTTPException(400, "No email returned — did you grant the email scope?")
# Gate: only provision if this person is an active financial contributor.
# We check the LIVE Open Collective membership list (authoritative), not
# the webhook's pending table (which only fires on new events). Matching is
# by email so guest contributors (no OC account) are still recognized.
if not await is_active_member(email):
logger.warning("Rejected non-member %s (email not an active contributor)", email)
html = """<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Not a member yet — Inference Cooperative</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
.note a { color: #6b7a62; }
</style>
</head>
<body>
<div class="card">
<h1>Not a member yet</h1>
<p>We couldn't find an active membership for your account. To join, contribute on our Open Collective page first, then return here to finish setup.</p>
<p class="note">Contributed as a guest? Make sure you've <a href="https://opencollective.com/signin">created an Open Collective account</a> with the same email you used to contribute, then try again.</p>
<p class="note">Questions? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
</div>
</body>
</html>"""
return Response(content=html, media_type="text/html", status_code=403)
# Provision the member
user_id = await cloudron_create_user(email, name)
await cloudron_set_group(user_id)
await cloudron_set_active(user_id, True)
key_token = await litellm_create_key(email, MEMBER_BUDGET)
store_member(email, key_token, user_id, slug)
await cloudron_send_invite(user_id, email)
await sync_loomio_memberships()
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
html = """<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're in — Inference Cooperative</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
.note a { color: #6b7a62; }
</style>
</head>
<body>
<div class="card">
<h1>You're in!</h1>
<p>Your account is being set up. Check your email for a link to create your account and start chatting.</p>
<p class="note">Didn't get it? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
</div>
</body>
</html>"""
return Response(content=html, media_type="text/html")
# --- D. Admin / health ---
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/admin/sync-loomio/{token}")
async def admin_sync_loomio(token: str):
"""Manually trigger a Loomio membership sync.
Protected by the same secret token as the Open Collective webhook. Useful
for reconciling pre-existing members (accounts created before the sync
existed) or recovering from a missed webhook. Idempotent — safe to call
repeatedly.
"""
if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):
raise HTTPException(401, "Invalid token")
await sync_loomio_memberships()
return {"status": "synced"}
@app.post("/admin/provision/{token}")
async def admin_provision(token: str, request: Request):
"""Manually provision a member by email (full pipeline).
Protected by the same secret token as the webhook. Runs the same
provisioning as the OAuth callback: Cloudron user + members group + LiteLLM
key + invite email + Loomio sync. Body: {"email": "...", "name": "..."}.
Idempotent — safe to call repeatedly.
"""
if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):
raise HTTPException(401, "Invalid token")
body = await request.json()
email = (body.get("email") or "").strip().lower()
name = body.get("name") or email.split("@")[0]
if not email:
raise HTTPException(400, "Missing email")
user_id = await cloudron_create_user(email, name)
await cloudron_set_group(user_id)
await cloudron_set_active(user_id, True)
key_token = await litellm_create_key(email, MEMBER_BUDGET)
store_member(email, key_token, user_id, "")
await cloudron_send_invite(user_id, email)
await sync_loomio_memberships()
logger.info("Manually provisioned member %s (user_id=%s)", email, user_id)
return {"status": "provisioned", "email": email, "user_id": user_id}
async def reconcile_memberships() -> dict:
"""Reconcile the portal against the live Open Collective member list.
- Provisions members who are BACKER/ADMIN on OC but not yet in our DB
(missed webhook, or contributed before the email step existed).
- Deactivates members who are in our DB but no longer BACKER/ADMIN
(lapsed or cancelled membership).
- Syncs Loomio to the resulting active-member set.
Idempotent and safe to run repeatedly. Returns a summary of actions taken.
"""
active = await fetch_members()
active_emails = {m["email"] for m in active}
# Currently provisioned members (from our DB).
conn = get_db()
rows = conn.execute("SELECT email, cloudron_user_id FROM members WHERE active = 1").fetchall()
conn.close()
provisioned = {r[0]: r[1] for r in rows}
provisioned_count = 0
deactivated_count = 0
# 1. Provision missing members.
for m in active:
email = m["email"]
if email not in provisioned:
try:
user_id = await cloudron_create_user(email, m["name"])
await cloudron_set_group(user_id)
await cloudron_set_active(user_id, True)
key_token = await litellm_create_key(email, MEMBER_BUDGET)
store_member(email, key_token, user_id, m["slug"])
await cloudron_send_invite(user_id, email)
provisioned_count += 1
logger.info("Sweep: provisioned %s", email)
except Exception as e:
logger.warning("Sweep: failed to provision %s: %s", email, e)
# 2. Deactivate lapsed members.
for email, user_id in provisioned.items():
if email not in active_emails:
try:
if INACTIVE_GROUP_ID and user_id:
await cloudron_set_group(user_id, INACTIVE_GROUP_ID)
deactivate_member(email)
deactivated_count += 1
logger.info("Sweep: deactivated %s", email)
except Exception as e:
logger.warning("Sweep: failed to deactivate %s: %s", email, e)
# 3. Sync Loomio.
await sync_loomio_memberships()
return {
"status": "reconciled",
"provisioned": provisioned_count,
"deactivated": deactivated_count,
}
@app.post("/admin/sweep/{token}")
async def admin_sweep(token: str):
"""Manually trigger a full membership reconciliation.
Protected by the same secret token as the webhook. Idempotent — safe to
call repeatedly. This is also what the Cloudron scheduler invokes on a
cron schedule (see the sweep script).
"""
if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN):
raise HTTPException(401, "Invalid token")
result = await reconcile_memberships()
return result
@app.get("/")
async def index():
return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"}