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"""
+
+
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)