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)