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
+77 -42

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
- [x] Verify Cloudron user-creation API payload (role/group assignment)
→ `POST /api/v1/users` with `{username, email, displayName, role, active}`
- [ ] Add a persistent store (SQLite/Postgres) for email→key mapping
- [ ] Add HMAC signature verification for the OC webhook
→ group assignment via `PUT /api/v1/users/:userId/groups` with `{groupIds: [...]}`
- [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
- [ ] Verify LiteLLM key/generate + key/list payload shapes against live gateway
+71 -39
View File
@@ -16,6 +16,7 @@ Three responsibilities:
import os
import json
import logging
import sqlite3
import httpx
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", "")
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"
# 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", "")
# 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 ---
def cloudron_headers() -> dict:
@@ -128,23 +178,16 @@ async def litellm_create_key(email: str, budget: float) -> str:
return r.json().get("key", "")
async def litellm_disable_key(email: str) -> None:
"""Disable a member's key (on payment lapse)."""
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:
# Find the key by alias
r = await client.get(
f"{LITELLM_BASE}/key/list",
await client.post(
f"{LITELLM_BASE}/key/delete",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
json={"keys": [key_token]},
)
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 ---
@@ -180,17 +223,17 @@ async def opencollective_webhook(request: Request):
# - "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})
key_token = await litellm_create_key(email, MEMBER_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"):
# 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": "ignored", "type": event_type})
@@ -205,19 +248,8 @@ async def inject_key(request: Request, path: str):
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
# 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")