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.
- plutus_agent/__init__.py +39 -0
- plutus_agent/__main__.py +7 -0
- plutus_agent/alerts.py +116 -0
- plutus_agent/billing/__init__.py +8 -0
- plutus_agent/billing/stripe_client.py +262 -0
- plutus_agent/bridge.py +32 -0
- plutus_agent/cli.py +449 -0
- plutus_agent/client.py +83 -0
- plutus_agent/config.py +171 -0
- plutus_agent/db.py +294 -0
- plutus_agent/demo.py +114 -0
- plutus_agent/integrations/__init__.py +14 -0
- plutus_agent/integrations/adapters.py +74 -0
- plutus_agent/integrations/claude_code_hook.py +80 -0
- plutus_agent/metering.py +312 -0
- plutus_agent/pricing.py +177 -0
- plutus_agent/reports.py +277 -0
- plutus_agent/server/__init__.py +4 -0
- plutus_agent/server/api.py +36 -0
- plutus_agent/server/app.py +242 -0
- plutus_agent/server/views.py +342 -0
- plutus_agent-0.2.0.dist-info/METADATA +253 -0
- plutus_agent-0.2.0.dist-info/RECORD +27 -0
- plutus_agent-0.2.0.dist-info/WHEEL +5 -0
- plutus_agent-0.2.0.dist-info/entry_points.txt +2 -0
- plutus_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- plutus_agent-0.2.0.dist-info/top_level.txt +1 -0
plutus_agent/__init__.py
ADDED
|
@@ -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}")
|
plutus_agent/__main__.py
ADDED
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,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
|