From 87e7c5b30e83786c4bde2d355e4f583859202a50 Mon Sep 17 00:00:00 2001 From: Inferencebot Date: Sat, 31 Oct 2026 16:25:36 +0000 Subject: [PATCH] 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. --- CloudronManifest.json | 2 +- app/main.py | 239 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 1 deletion(-) diff --git a/CloudronManifest.json b/CloudronManifest.json index 1641d29..ce63d70 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -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" } diff --git a/app/main.py b/app/main.py index 5178916..337be5b 100644 --- a/app/main.py +++ b/app/main.py @@ -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 = """""" + + +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""" + + + + + + + +
+ + + + + + + + + + +
+ {PINE_LOGO_SVG} +
+

{heading}

+

{lede}

+

{money}

+ Add credits +

Credit packs never expire, carry over month to month, and are drawn on only after your monthly credit is used.

+
+ 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.
+ The Inference Cooperative · inference.coop +
+
+ +""" + 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)