Add SQLite store for email→key mapping; fix key/list (returns token strings); flat 5 budget
This commit is contained in:
1 parent
c1efd39c5c
commit
ee265ee18f
2 files changed
+77
-42
No files matched your search
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user