plutus-agent 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ """Plutus — the billing layer for AI agents.
2
+
3
+ Self-hosted, Stripe-integrated usage metering and prepaid-credit billing for
4
+ LLM / AI-agent spend. Multi-tenant (organizations → workspaces → users), meters
5
+ usage per workspace / provider / task-type, depletes prepaid credits as calls
6
+ route through, and serves a dark-themed real-time dashboard at :8420.
7
+
8
+ Everything except Stripe works fully offline. State lives in SQLite
9
+ (``~/.plutus/plutus.db`` by default); configuration in ``~/.plutus/config.yaml``.
10
+
11
+ This package is the *monetization engine*. The original credit monitor and
12
+ runway router (``plutus.py`` / ``plutus_route.py`` at the repo root) remain the
13
+ live FinOps tools; the engine bridges to them via ``plutus_agent.bridge`` rather
14
+ than importing them, so the two can ship and run independently.
15
+ """
16
+
17
+ __version__ = "0.2.0"
18
+ __product__ = "Plutus"
19
+ __tagline__ = "The billing layer for AI agents."
20
+ __company__ = "Perseus Computing LLC"
21
+ __homepage__ = "https://perseus.observer/plutus/"
22
+ __default_port__ = 8420
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ "__product__",
27
+ "__tagline__",
28
+ "__default_port__",
29
+ "Meter",
30
+ ]
31
+
32
+
33
+ def __getattr__(name):
34
+ # Lazy export so `from plutus_agent import Meter` works without importing
35
+ # the world at package-load time.
36
+ if name == "Meter":
37
+ from .client import Meter
38
+ return Meter
39
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,7 @@
1
+ """``python -m plutus_agent`` → the CLI."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
plutus_agent/alerts.py ADDED
@@ -0,0 +1,116 @@
1
+ """Alerts — email notifications for low balance and budget caps.
2
+
3
+ Alerts are *raised* during metering (``metering._check_thresholds`` logs them to
4
+ ``alerts_log``). This module is the *delivery* side: it picks up undelivered
5
+ alerts and emails them via SMTP. It is offline-safe — with alerts disabled or
6
+ SMTP unconfigured, :func:`send_pending` runs as a dry run, returning what *would*
7
+ be sent without touching the network, and leaves the alerts marked undelivered.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import smtplib
12
+ import ssl
13
+ from email.message import EmailMessage
14
+ from typing import Optional
15
+
16
+ from . import db
17
+
18
+
19
+ def pending(conn, org_id: str) -> list[dict]:
20
+ rows = conn.execute(
21
+ "SELECT * FROM alerts_log WHERE org_id=? AND delivered=0 ORDER BY ts",
22
+ (org_id,),
23
+ ).fetchall()
24
+ return [dict(r) for r in rows]
25
+
26
+
27
+ def _mark_delivered(conn, alert_id: str) -> None:
28
+ conn.execute("UPDATE alerts_log SET delivered=1 WHERE id=?", (alert_id,))
29
+ conn.commit()
30
+
31
+
32
+ def _build_message(alert: dict, from_addr: str, to_addrs: list[str],
33
+ org_name: str) -> EmailMessage:
34
+ kind = alert["kind"].replace("_", " ").title()
35
+ msg = EmailMessage()
36
+ msg["Subject"] = f"[Plutus] {kind} — {org_name}"
37
+ msg["From"] = from_addr
38
+ msg["To"] = ", ".join(to_addrs)
39
+ msg.set_content(
40
+ f"{alert['message']}\n\n"
41
+ f"Organization: {org_name}\n"
42
+ f"Alert type: {alert['kind']}\n\n"
43
+ f"— Plutus, the billing layer for AI agents\n"
44
+ f" https://perseus.observer/plutus/\n"
45
+ )
46
+ return msg
47
+
48
+
49
+ def send_pending(conn, cfg: dict, org_id: str,
50
+ force: bool = False) -> dict:
51
+ """Deliver undelivered alerts for an org. Returns a summary.
52
+
53
+ ``force`` bypasses the ``alerts.enabled`` gate (used by ``plutus alerts
54
+ --test``). SMTP is still required to actually send; without it this is a dry
55
+ run.
56
+ """
57
+ acfg = cfg.get("alerts", {})
58
+ org = db.get_org(conn, org_id)
59
+ org_name = org["name"] if org else org_id
60
+ items = pending(conn, org_id)
61
+ if not items:
62
+ return {"sent": 0, "dry_run": False, "pending": 0, "detail": "nothing pending"}
63
+
64
+ enabled = acfg.get("enabled") or force
65
+ to_addrs = acfg.get("to_addrs") or []
66
+ smtp_host = acfg.get("smtp_host") or ""
67
+ can_send = bool(enabled and smtp_host and to_addrs)
68
+
69
+ if not can_send:
70
+ reason = []
71
+ if not enabled:
72
+ reason.append("alerts.enabled is false")
73
+ if not smtp_host:
74
+ reason.append("no smtp_host")
75
+ if not to_addrs:
76
+ reason.append("no to_addrs")
77
+ return {
78
+ "sent": 0, "dry_run": True, "pending": len(items),
79
+ "would_send": [a["message"] for a in items],
80
+ "detail": "dry run — " + "; ".join(reason),
81
+ }
82
+
83
+ from_addr = acfg.get("from_addr", "plutus@perseus.observer")
84
+ port = int(acfg.get("smtp_port", 587))
85
+ user = acfg.get("smtp_user") or ""
86
+ password = acfg.get("smtp_password") or ""
87
+
88
+ sent = 0
89
+ errors = []
90
+ try:
91
+ ctx = ssl.create_default_context()
92
+ with smtplib.SMTP(smtp_host, port, timeout=20) as server:
93
+ server.ehlo()
94
+ if port in (587,):
95
+ server.starttls(context=ctx)
96
+ server.ehlo()
97
+ if user:
98
+ server.login(user, password)
99
+ for a in items:
100
+ try:
101
+ server.send_message(_build_message(a, from_addr, to_addrs, org_name))
102
+ _mark_delivered(conn, a["id"])
103
+ sent += 1
104
+ except Exception as e: # one bad alert shouldn't sink the rest
105
+ errors.append(f"{a['id']}: {e}")
106
+ except Exception as e:
107
+ return {"sent": sent, "dry_run": False, "pending": len(items) - sent,
108
+ "error": str(e)}
109
+ return {"sent": sent, "dry_run": False, "pending": len(items) - sent,
110
+ "errors": errors or None}
111
+
112
+
113
+ def check_and_notify(conn, cfg: dict, org_id: Optional[str] = None) -> list[dict]:
114
+ """Convenience for cron: deliver pending alerts for one or all orgs."""
115
+ org_ids = [org_id] if org_id else [o["id"] for o in db.list_orgs(conn)]
116
+ return [{"org_id": oid, **send_pending(conn, cfg, oid)} for oid in org_ids]
@@ -0,0 +1,8 @@
1
+ """Billing — Stripe integration (optional, test-mode friendly)."""
2
+ from .stripe_client import (
3
+ StripeClient,
4
+ BillingError,
5
+ handle_webhook_event,
6
+ )
7
+
8
+ __all__ = ["StripeClient", "BillingError", "handle_webhook_event"]
@@ -0,0 +1,262 @@
1
+ """Stripe client — Checkout for prepaid credits + Pro subscription, Customer
2
+ Portal, and webhook handling.
3
+
4
+ Stripe is **optional**. With no secret key the client reports ``available ==
5
+ False`` and every method raises :class:`BillingError` with a clear message, so
6
+ the dashboard can show a "connect Stripe to enable billing" state while every
7
+ non-Stripe feature keeps working offline. With a *test-mode* key
8
+ (``sk_test_...``) the full flow works end-to-end against Stripe's test
9
+ environment.
10
+
11
+ Two purchase paths:
12
+
13
+ * **Prepaid credits** — a one-time Checkout Session (``mode=payment``) for a
14
+ chosen dollar amount. On ``checkout.session.completed`` we top up the org's
15
+ credit ledger by the amount paid.
16
+ * **Pro plan** — a subscription Checkout Session (``mode=subscription``) against
17
+ the configured Price. Subscription lifecycle webhooks move the org between the
18
+ ``pro`` and ``free`` tiers.
19
+
20
+ Webhook handling is **idempotent**: every event id is recorded in
21
+ ``stripe_events`` and never applied twice.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import Optional
26
+
27
+ from .. import db
28
+
29
+
30
+ class BillingError(RuntimeError):
31
+ pass
32
+
33
+
34
+ def _load_stripe():
35
+ try:
36
+ import stripe # type: ignore
37
+ return stripe
38
+ except ImportError:
39
+ return None
40
+
41
+
42
+ class StripeClient:
43
+ def __init__(self, cfg: dict):
44
+ self.cfg = cfg or {}
45
+ self.billing = self.cfg.get("billing", {})
46
+ self.secret = self.billing.get("stripe_secret_key") or ""
47
+ self.publishable = self.billing.get("stripe_publishable_key") or ""
48
+ self.webhook_secret = self.billing.get("stripe_webhook_secret") or ""
49
+ self.currency = (self.billing.get("currency") or "usd").lower()
50
+ self._stripe = _load_stripe()
51
+ if self._stripe and self.secret:
52
+ self._stripe.api_key = self.secret
53
+
54
+ # ------------------------------------------------------------- status ---
55
+ @property
56
+ def available(self) -> bool:
57
+ return bool(self._stripe and self.secret)
58
+
59
+ @property
60
+ def test_mode(self) -> bool:
61
+ return self.secret.startswith("sk_test_")
62
+
63
+ def status(self) -> dict:
64
+ if not self._stripe:
65
+ mode = "offline (stripe SDK not installed)"
66
+ elif not self.secret:
67
+ mode = "offline (no API key)"
68
+ elif self.test_mode:
69
+ mode = "test mode"
70
+ else:
71
+ mode = "live mode"
72
+ return {
73
+ "available": self.available,
74
+ "test_mode": self.test_mode,
75
+ "mode": mode,
76
+ "publishable_key": self.publishable,
77
+ "has_pro_price": bool(self.billing.get("stripe_price_pro")),
78
+ }
79
+
80
+ def _require(self):
81
+ if not self._stripe:
82
+ raise BillingError(
83
+ "Stripe SDK not installed. `pip install stripe` to enable billing."
84
+ )
85
+ if not self.secret:
86
+ raise BillingError(
87
+ "No Stripe key configured. Set billing.stripe_secret_key or "
88
+ "the STRIPE_SECRET_KEY env var."
89
+ )
90
+
91
+ # ----------------------------------------------------------- customer ---
92
+ def ensure_customer(self, conn, org_id: str) -> str:
93
+ """Get-or-create a Stripe customer for an org; persist the id."""
94
+ self._require()
95
+ org = db.get_org(conn, org_id)
96
+ if org is None:
97
+ raise BillingError(f"unknown org {org_id}")
98
+ if org["stripe_customer_id"]:
99
+ return org["stripe_customer_id"]
100
+ owner = conn.execute(
101
+ "SELECT email FROM users WHERE org_id=? ORDER BY created_at LIMIT 1",
102
+ (org_id,),
103
+ ).fetchone()
104
+ cust = self._stripe.Customer.create(
105
+ name=org["name"],
106
+ email=owner["email"] if owner else None,
107
+ metadata={"plutus_org_id": org_id},
108
+ )
109
+ db.set_stripe_customer(conn, org_id, cust["id"])
110
+ return cust["id"]
111
+
112
+ # ----------------------------------------------------------- checkout ---
113
+ def credit_checkout(self, conn, org_id: str, amount_usd: float) -> dict:
114
+ """One-time Checkout Session to buy ``amount_usd`` of prepaid credit."""
115
+ self._require()
116
+ if amount_usd <= 0:
117
+ raise BillingError("amount must be positive")
118
+ customer = self.ensure_customer(conn, org_id)
119
+ session = self._stripe.checkout.Session.create(
120
+ mode="payment",
121
+ customer=customer,
122
+ line_items=[{
123
+ "price_data": {
124
+ "currency": self.currency,
125
+ "unit_amount": int(round(amount_usd * 100)),
126
+ "product_data": {
127
+ "name": "Plutus prepaid credit",
128
+ "description": f"${amount_usd:,.2f} of agent spend credit",
129
+ },
130
+ },
131
+ "quantity": 1,
132
+ }],
133
+ success_url=self.billing.get("success_url", ""),
134
+ cancel_url=self.billing.get("cancel_url", ""),
135
+ metadata={"plutus_org_id": org_id, "kind": "credit",
136
+ "amount_usd": f"{amount_usd:.2f}"},
137
+ )
138
+ return {"id": session["id"], "url": session["url"]}
139
+
140
+ def pro_checkout(self, conn, org_id: str) -> dict:
141
+ """Subscription Checkout Session for the $20/mo Pro plan."""
142
+ self._require()
143
+ price = self.billing.get("stripe_price_pro")
144
+ if not price:
145
+ raise BillingError(
146
+ "No Pro price configured. Set billing.stripe_price_pro to the "
147
+ "Stripe Price ID for the $20/mo plan."
148
+ )
149
+ customer = self.ensure_customer(conn, org_id)
150
+ session = self._stripe.checkout.Session.create(
151
+ mode="subscription",
152
+ customer=customer,
153
+ line_items=[{"price": price, "quantity": 1}],
154
+ success_url=self.billing.get("success_url", ""),
155
+ cancel_url=self.billing.get("cancel_url", ""),
156
+ metadata={"plutus_org_id": org_id, "kind": "subscription"},
157
+ )
158
+ return {"id": session["id"], "url": session["url"]}
159
+
160
+ def portal(self, conn, org_id: str, return_url: Optional[str] = None) -> dict:
161
+ """Stripe Customer Portal session for self-serve billing management."""
162
+ self._require()
163
+ customer = self.ensure_customer(conn, org_id)
164
+ session = self._stripe.billing_portal.Session.create(
165
+ customer=customer,
166
+ return_url=return_url or self.billing.get("success_url", ""),
167
+ )
168
+ return {"url": session["url"]}
169
+
170
+ # ------------------------------------------------------------ webhook ---
171
+ def construct_event(self, payload: bytes, sig_header: str):
172
+ """Verify + parse a webhook payload into a Stripe event object."""
173
+ self._require()
174
+ if not self.webhook_secret:
175
+ raise BillingError("No webhook secret configured "
176
+ "(billing.stripe_webhook_secret).")
177
+ return self._stripe.Webhook.construct_event(
178
+ payload, sig_header, self.webhook_secret
179
+ )
180
+
181
+
182
+ # --------------------------------------------------------- webhook applying ---
183
+ def handle_webhook_event(conn, event: dict) -> dict:
184
+ """Apply a verified Stripe event to the database (idempotent).
185
+
186
+ ``event`` is a dict-like Stripe Event (``event["type"]``, ``event["data"]
187
+ ["object"]``, ``event["id"]``). Returns a small summary for logging.
188
+ """
189
+ event_id = event.get("id", "")
190
+ etype = event.get("type", "")
191
+ if event_id and db.stripe_event_seen(conn, event_id):
192
+ return {"status": "duplicate", "type": etype, "id": event_id}
193
+
194
+ obj = (event.get("data") or {}).get("object") or {}
195
+ result = {"status": "ignored", "type": etype, "id": event_id}
196
+
197
+ if etype == "checkout.session.completed":
198
+ result = _apply_checkout_completed(conn, obj)
199
+ elif etype in ("customer.subscription.created", "customer.subscription.updated"):
200
+ result = _apply_subscription_change(conn, obj)
201
+ elif etype == "customer.subscription.deleted":
202
+ result = _apply_subscription_deleted(conn, obj)
203
+
204
+ if event_id:
205
+ db.mark_stripe_event(conn, event_id, etype)
206
+ result.setdefault("type", etype)
207
+ result.setdefault("id", event_id)
208
+ return result
209
+
210
+
211
+ def _org_from_metadata_or_customer(conn, obj) -> Optional[str]:
212
+ meta = obj.get("metadata") or {}
213
+ org_id = meta.get("plutus_org_id")
214
+ if org_id and db.get_org(conn, org_id):
215
+ return org_id
216
+ customer = obj.get("customer")
217
+ if customer:
218
+ row = db.org_by_stripe_customer(conn, customer)
219
+ if row:
220
+ return row["id"]
221
+ return None
222
+
223
+
224
+ def _apply_checkout_completed(conn, obj) -> dict:
225
+ org_id = _org_from_metadata_or_customer(conn, obj)
226
+ if not org_id:
227
+ return {"status": "no_org", "detail": "could not map checkout to an org"}
228
+ meta = obj.get("metadata") or {}
229
+ kind = meta.get("kind")
230
+ if kind == "credit" or obj.get("mode") == "payment":
231
+ amount = meta.get("amount_usd")
232
+ if amount is not None:
233
+ usd = float(amount)
234
+ else:
235
+ usd = float(obj.get("amount_total") or 0) / 100.0
236
+ row = db.add_ledger(conn, org_id, usd, "topup",
237
+ reason="Stripe checkout", stripe_ref=obj.get("id"))
238
+ return {"status": "credited", "org_id": org_id, "amount_usd": usd,
239
+ "balance_after": float(row["balance_after"])}
240
+ if kind == "subscription" or obj.get("mode") == "subscription":
241
+ db.set_org_tier(conn, org_id, "pro")
242
+ return {"status": "subscribed", "org_id": org_id, "tier": "pro"}
243
+ return {"status": "ignored", "org_id": org_id}
244
+
245
+
246
+ def _apply_subscription_change(conn, obj) -> dict:
247
+ org_id = _org_from_metadata_or_customer(conn, obj)
248
+ if not org_id:
249
+ return {"status": "no_org"}
250
+ status = obj.get("status")
251
+ tier = "pro" if status in ("active", "trialing", "past_due") else "free"
252
+ db.set_org_tier(conn, org_id, tier)
253
+ return {"status": "tier_set", "org_id": org_id, "tier": tier,
254
+ "subscription_status": status}
255
+
256
+
257
+ def _apply_subscription_deleted(conn, obj) -> dict:
258
+ org_id = _org_from_metadata_or_customer(conn, obj)
259
+ if not org_id:
260
+ return {"status": "no_org"}
261
+ db.set_org_tier(conn, org_id, "free")
262
+ return {"status": "downgraded", "org_id": org_id, "tier": "free"}
plutus_agent/bridge.py ADDED
@@ -0,0 +1,32 @@
1
+ """Bridge to the live runway monitor (repo-root ``plutus.py``).
2
+
3
+ Decoupled by design: rather than importing the production monitor (which has its
4
+ own module-level paths and is pulled to the Hermes host independently), the
5
+ engine shells out to it for ``--json`` exactly as ``plutus_route.py`` does. If
6
+ the monitor isn't configured or fails, the dashboard simply omits the live
7
+ provider-runway panel — the rest of Plutus works fully offline.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shlex
13
+ import subprocess
14
+ import sys
15
+
16
+
17
+ def runway(monitor_cfg: dict) -> dict | None:
18
+ """Return the monitor's ``collect()`` JSON, or ``None`` if unavailable."""
19
+ if not monitor_cfg or not monitor_cfg.get("enabled"):
20
+ return None
21
+ cmd = monitor_cfg.get("command", "").strip()
22
+ if not cmd:
23
+ return None
24
+ try:
25
+ argv = shlex.split(cmd) + ["--json"]
26
+ out = subprocess.run(argv, capture_output=True, text=True, timeout=30)
27
+ if out.returncode != 0 or not out.stdout.strip():
28
+ return None
29
+ return json.loads(out.stdout)
30
+ except Exception as e: # never let the bridge break the dashboard
31
+ sys.stderr.write(f"plutus: monitor bridge unavailable: {e}\n")
32
+ return None