portal: budget notices by email, off by default

Warns a member at 80% of their monthly cap and again when it is exhausted, using the welcome email's template shell. Spend and budget come from LiteLLM's Postgres, the same source the dashboard shows. One notice per level per budget cycle. BUDGET_NOTICES=off|dryrun|on gates sending; POST /admin/budget-notices previews at any time and refuses to send unless the flag is on. Rides the existing sweep cron, wrapped so it cannot break reconciliation.
This commit is contained in:
inference-bot committed 2026-10-31 16:25:36 +00:00
1 parent 76d59b3fb6
commit 87e7c5b30e
2 files changed
+240 -1

No files matched your search

+1 -1
View File
@@ -31,5 +31,5 @@
"manifestVersion": 2,
"website": "https://inference.coop",
"contactEmail": "info@inference.coop",
"dockerImage": "registry.inference.coop/co-op/member-portal:20261030-6475a51"
"dockerImage": "registry.inference.coop/co-op/member-portal:20261031-76d59b3"
}
+239
View File
@@ -3885,6 +3885,14 @@ async def admin_sweep(request: Request):
_verify_admin_token(request)
result = await reconcile_memberships()
# Budget notices ride the same cron the Cloudron scheduler already hits. They are OFF
# unless BUDGET_NOTICES=on, and wrapped so a failure here can never break reconciliation.
try:
result["budget_notices"] = await budget_notice_sweep(send=(budget_notices_enabled() == "on"))
except Exception as exc: # noqa: BLE001
result["budget_notices"] = {"error": str(exc)[:200]}
return result
@@ -3947,3 +3955,234 @@ async def admin_librechat_keys(request: Request):
@app.get("/")
async def index():
return {"service": "Inference Cooperative Member Portal", "version": "0.1.0"}
# --------------------------------------------------------------------------- #
# Budget notices — email a member as they approach their monthly limit. #
# --------------------------------------------------------------------------- #
# OFF by default: BUDGET_NOTICES = off | dryrun | on. Nothing is ever emailed
# unless it is "on"; the admin endpoint below can preview at any time, including
# while off. One notice per level per budget cycle, so a member hears from us
# once when they cross 80% and once when they reach the cap — never on every
# sweep. Spend and budget are read from LiteLLM's own Postgres, the same source
# the dashboard and admin panel display, so the numbers cannot drift.
#
# CREDIT_PACK_URL is configuration because the website has no dedicated pack
# link today; the Open Collective contribution page is what actually credits a
# member (the webhook settles contributions into credits).
BUDGET_WARN_AT = 0.80 # fraction of the cap that triggers the warning
PINE_LOGO_SVG = """<svg width="56" height="56" viewBox="0 0 130 130" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="65" cy="14" r="5" fill="#5b8c5a"/>
<circle cx="58" cy="30" r="6" fill="#7ab87a"/>
<circle cx="74" cy="30" r="4" fill="#6ba86b"/>
<circle cx="51.5" cy="46" r="6.5" fill="#5b8c5a"/>
<circle cx="65" cy="46" r="5" fill="#7ab87a"/>
<circle cx="81" cy="46" r="4" fill="#6ba86b"/>
<circle cx="44" cy="62" r="7" fill="#6ba86b"/>
<circle cx="61" cy="62" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="62" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="62" r="3.5" fill="#6ba86b"/>
<circle cx="36.5" cy="78" r="7.5" fill="#5b8c5a"/>
<circle cx="54" cy="78" r="6" fill="#7ab87a"/>
<circle cx="70" cy="78" r="5" fill="#6ba86b"/>
<circle cx="84" cy="78" r="4" fill="#5b8c5a"/>
<circle cx="97.5" cy="78" r="3.5" fill="#7ab87a"/>
<circle cx="44" cy="94" r="6.5" fill="#6ba86b"/>
<circle cx="61" cy="94" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="94" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="94" r="3.5" fill="#6ba86b"/>
<circle cx="51.5" cy="110" r="5.5" fill="#5b8c5a"/>
<circle cx="65" cy="110" r="4.5" fill="#7ab87a"/>
<circle cx="81" cy="110" r="3.5" fill="#6ba86b"/>
</svg>"""
def budget_notices_enabled() -> str:
"""BUDGET_NOTICES: off (default) | dryrun | on."""
return (os.environ.get("BUDGET_NOTICES", "off") or "off").strip().lower()
def credit_pack_url() -> str:
return os.environ.get(
"CREDIT_PACK_URL", "https://opencollective.com/inference-cooperative/contribute"
)
def _ensure_budget_notices_table(conn) -> None:
conn.execute(
"CREATE TABLE IF NOT EXISTS budget_notices ("
" email TEXT NOT NULL, level TEXT NOT NULL, cycle TEXT NOT NULL,"
" sent_at TEXT NOT NULL, detail TEXT,"
" PRIMARY KEY (email, level, cycle))"
)
conn.commit()
def budget_notice_email(
name: str, level: str, spend: float, budget: float, pack_url: str
) -> tuple[str, str, str]:
"""Build a budget notice (subject, text, html).
Deliberately the same shell as welcome_email(): cream background, white card,
forest-green button, inline pine-tree mark, table-based layout, no external
assets. Only the words and the call to action differ, so a member sees one
consistent co-op identity rather than a system notice bolted on beside it.
"""
pct = (spend / budget * 100.0) if budget else 0.0
if level == "exhausted":
subject = "Your monthly Inference Cooperative credit is used up"
heading = "You've used your monthly credit"
lede = ("Your full monthly credit is used, so chat and API requests will stop "
"until your credit resets or you add more.")
else:
subject = f"You've used {pct:.0f}% of your monthly Inference Cooperative credit"
heading = f"You're at {pct:.0f}% of your monthly credit"
lede = ("You're getting close to the end of your monthly credit. When it runs "
"out, chat and API requests stop until it resets.")
money = f"${spend:.2f} of ${budget:.2f} used"
text = (
f"Hi {name},\n\n"
f"{lede}\n\n"
f"{money}\n\n"
"If you'd like more, credit packs add to your account on top of the monthly "
"credit. They never expire, carry over month to month, and are only drawn on "
"once your monthly credit is used:\n\n"
f"{pack_url}\n\n"
"Nothing else changes — your membership, your data and your account stay as "
"they are. Questions? Reply to this email or write to info@inference.coop.\n\n"
"— The Inference Cooperative\n"
)
html = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#faf8f5;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#faf8f5;padding:40px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;">
<tr>
<td align="center" style="padding-bottom:24px;">
{PINE_LOGO_SVG}
</td>
</tr>
<tr>
<td align="center" style="background:#ffffff;border:1px solid #e8e2d8;border-radius:16px;padding:40px 32px;">
<h1 style="margin:0 0 12px;font-size:24px;font-weight:700;color:#2d3327;">{heading}</h1>
<p style="margin:0 0 8px;font-size:16px;color:#6b7a62;line-height:1.5;">{lede}</p>
<p style="margin:0 0 24px;font-size:15px;color:#2d3327;line-height:1.5;"><strong>{money}</strong></p>
<a href="{pack_url}" style="display:inline-block;background:#5b8c5a;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:999px;font-size:16px;font-weight:600;">Add credits</a>
<p style="margin:28px 0 0;font-size:13px;color:#94a08c;line-height:1.5;">Credit packs never expire, carry over month to month, and are drawn on only after your monthly credit is used.</p>
</td>
</tr>
<tr>
<td align="center" style="padding-top:24px;font-size:13px;color:#94a08c;line-height:1.6;">
Nothing else changes — your membership, your data and your account stay as they are.<br>
Questions? Reply to this email or write to <a href="mailto:info@inference.coop" style="color:#6b7a62;">info@inference.coop</a>.<br>
<span style="color:#2d3327;">The Inference Cooperative</span> &middot; inference.coop
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
return subject, text, html
async def budget_notice_sweep(send: bool = False) -> dict:
"""Find members near or over their monthly cap, and optionally email them.
`send=False` is a preview: it computes exactly who would be written to, with
the numbers, and sends nothing. `send=True` emails and records the send — the
caller is responsible for requiring BUDGET_NOTICES=on first.
"""
mode = budget_notices_enabled()
if send and mode != "on":
return {"refused": f"BUDGET_NOTICES is '{mode}', not 'on' — nothing sent"}
conn = get_db()
_ensure_budget_notices_table(conn)
cols = {r[1] for r in conn.execute("PRAGMA table_info(members)")}
name_col = "name" if "name" in cols else ("display_name" if "display_name" in cols else None)
sel = "SELECT email" + (f", {name_col}" if name_col else "") + " FROM members WHERE active = 1"
rows = conn.execute(sel).fetchall()
pack = credit_pack_url()
preview, sent, skipped = [], 0, 0
for row in rows:
email = row[0]
name = (row[1] if len(row) > 1 and row[1] else str(email).split("@")[0])
team_id = await litellm_team_id_for_email(email)
if not team_id:
skipped += 1
continue
stats = get_team_budget_from_db(team_id) or {}
budget = stats.get("max_budget")
spend = float(stats.get("spend") or 0.0)
if not budget or budget <= 0:
skipped += 1 # unlimited: nothing to warn about
continue
cycle = str(stats.get("budget_reset_at") or stats.get("reset_at") or "cycle")[:19]
if spend >= budget:
level = "exhausted"
elif spend >= BUDGET_WARN_AT * budget:
level = "warning"
else:
continue
already = conn.execute(
"SELECT 1 FROM budget_notices WHERE email = ? AND level = ? AND cycle = ?",
(email, level, cycle),
).fetchone()
if already:
skipped += 1
continue
subject, text_body, html_body = budget_notice_email(name, level, spend, budget, pack)
entry = {
"email": email, "level": level,
"spend": round(spend, 2), "budget": round(budget, 2),
"pct": round(spend / budget * 100, 1), "cycle": cycle,
}
if send:
ok = send_email(email, subject, text_body, html_body)
entry["sent"] = bool(ok)
if ok:
conn.execute(
"INSERT OR REPLACE INTO budget_notices"
" (email, level, cycle, sent_at, detail) VALUES (?, ?, ?, datetime('now'), ?)",
(email, level, cycle, f"spend={spend:.4f} budget={budget:.4f}"),
)
conn.commit()
sent += 1
else:
entry["subject"] = subject
preview.append(entry)
continue
preview.append(entry)
conn.close()
if send:
return {"mode": mode, "sent": sent, "considered": len(rows),
"skipped": skipped, "notices": preview}
return {"mode": mode, "dry_run": True, "considered": len(rows), "skipped": skipped,
"would_notify": len(preview), "notices": preview}
@app.post("/admin/budget-notices")
@limiter.limit("10/minute")
async def admin_budget_notices(request: Request, mode: str = "dryrun"):
"""Preview (default) or send the monthly budget notices.
Authenticated by X-Admin-Token. `mode=dryrun` never emails; `mode=send`
requires BUDGET_NOTICES=on and refuses otherwise, so a preview can be run at
any time without arming the sender.
"""
_verify_admin_token(request)
if mode == "send":
if budget_notices_enabled() != "on":
return {"refused": f"BUDGET_NOTICES is '{budget_notices_enabled()}', not 'on'"
" — set it on the portal app first"}
return await budget_notice_sweep(send=True)
return await budget_notice_sweep(send=False)