Add SQLite store for email→key mapping; fix key/list (returns token strings); flat 5 budget

This commit is contained in:
inference-bot committed 2026-09-04 17:28:46 -06:00
1 parent c1efd39c5c
commit ee265ee18f
2 files changed
+75 -40

No files matched your search

+6 -3
View File
@@ -82,7 +82,10 @@ points. Remaining work before production:
→ `order.processed` (every payment), `new member` (first only), `firstPayment` flag → `order.processed` (every payment), `new member` (first only), `firstPayment` flag
- [x] Verify Cloudron user-creation API payload (role/group assignment) - [x] Verify Cloudron user-creation API payload (role/group assignment)
→ `POST /api/v1/users` with `{username, email, displayName, role, active}` → `POST /api/v1/users` with `{username, email, displayName, role, active}`
- [ ] Add a persistent store (SQLite/Postgres) for email→key mapping → group assignment via `PUT /api/v1/users/:userId/groups` with `{groupIds: [...]}`
- [ ] Add HMAC signature verification for the OC webhook - [x] Add a persistent store (SQLite) for email→key mapping
→ `key/list` returns token strings (not objects), so we store email→token locally
- [x] Verify LiteLLM key/generate + key/list payload shapes against live gateway
→ `key/generate` returns `{key: "sk-..."}`; `key/list` returns `{keys: ["<token>", ...]}`
- [ ] Add HMAC signature verification for the OC webhook (if OC supports it)
- [ ] Wire the OIDC addon for admin access - [ ] Wire the OIDC addon for admin access
- [ ] Verify LiteLLM key/generate + key/list payload shapes against live gateway
+69 -37
View File
@@ -16,6 +16,7 @@ Three responsibilities:
import os import os
import json import json
import logging import logging
import sqlite3
import httpx import httpx
from fastapi import FastAPI, Request, Response, HTTPException from fastapi import FastAPI, Request, Response, HTTPException
@@ -33,18 +34,67 @@ LITELLM_BASE = os.environ.get("LITELLM_BASE", "https://gateway.inference.coop")
LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "") LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "") OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "")
# Tier → monthly budget (in USD of tokens). Governance decision, set in Loomio. # Monthly credit budget (in USD of tokens) for all members.
TIER_BUDGETS = { # Single sliding-scale tier: everyone gets the same $15/month in credits,
"free": 2.0, # regardless of their $10/15/20 contribution. Governance decision (Loomio).
"member": 15.0, MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0"))
"supporter": 30.0,
}
DEFAULT_TIER = "member"
# The "members" group in Cloudron (group-based access control). # The "members" group in Cloudron (group-based access control).
# Members are assigned to this group, which grants access to the chat app. # Members are assigned to this group, which grants access to the chat app.
MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "") MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "")
# 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")
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, "
"active INTEGER DEFAULT 1"
")"
)
return conn
def store_member(email: str, key_token: str, cloudron_user_id: str) -> None:
conn = get_db()
conn.execute(
"INSERT INTO members (email, key_token, cloudron_user_id, active) "
"VALUES (?, ?, ?, 1) "
"ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, "
"cloudron_user_id=excluded.cloudron_user_id, active=1",
(email, key_token, cloudron_user_id),
)
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 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 --- # --- Helpers ---
def cloudron_headers() -> dict: def cloudron_headers() -> dict:
@@ -128,23 +178,16 @@ async def litellm_create_key(email: str, budget: float) -> str:
return r.json().get("key", "") return r.json().get("key", "")
async def litellm_disable_key(email: str) -> None: async def litellm_disable_key(key_token: str) -> None:
"""Disable a member's key (on payment lapse).""" """Delete a member's LiteLLM key (on payment lapse)."""
if not key_token:
return
async with httpx.AsyncClient() as client: 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( await client.post(
f"{LITELLM_BASE}/key/delete", f"{LITELLM_BASE}/key/delete",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"}, headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
json={"keys": [k["token"]]}, json={"keys": [key_token]},
) )
return
# --- A. Open Collective webhook --- # --- A. Open Collective webhook ---
@@ -180,17 +223,17 @@ async def opencollective_webhook(request: Request):
# - "collective.transaction.created" is DEPRECATED (being removed) # - "collective.transaction.created" is DEPRECATED (being removed)
if event_type in ("order.processed", "new.member", "collective.member.created"): if event_type in ("order.processed", "new.member", "collective.member.created"):
# New or renewed member → ensure active # 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) user_id = await cloudron_create_user(email, name)
await cloudron_set_group(user_id) await cloudron_set_group(user_id)
await cloudron_set_active(user_id, True) await cloudron_set_active(user_id, True)
await litellm_create_key(email, budget) key_token = await litellm_create_key(email, MEMBER_BUDGET)
return JSONResponse({"status": "activated", "user_id": user_id, "budget": budget}) store_member(email, key_token, user_id)
return JSONResponse({"status": "activated", "user_id": user_id, "budget": MEMBER_BUDGET})
if event_type in ("collective.member.deleted", "collective.transaction.deleted"): if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
# Lapsed member → deactivate # Lapsed member → deactivate
await litellm_disable_key(email) key_token = deactivate_member(email)
await litellm_disable_key(key_token)
return JSONResponse({"status": "deactivated"}) return JSONResponse({"status": "deactivated"})
return JSONResponse({"status": "ignored", "type": event_type}) return JSONResponse({"status": "ignored", "type": event_type})
@@ -205,19 +248,8 @@ async def inject_key(request: Request, path: str):
if not email: if not email:
raise HTTPException(401, "No member identity (x-user-email header)") raise HTTPException(401, "No member identity (x-user-email header)")
# Look up the member's key (in production: from a store keyed by email) # Look up the member's key from the persistent store
# For MVP: derive deterministically or look up via LiteLLM member_key = get_member_key(email)
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: if not member_key:
raise HTTPException(403, "No active membership key") raise HTTPException(403, "No active membership key")