burnwatch 0.1.0__tar.gz

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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: burnwatch
3
+ Version: 0.1.0
4
+ Summary: Burnwatch SDK — mirror your AI agent's payments to Burnwatch for spend monitoring.
5
+ Project-URL: Homepage, https://getburnwatch.southforgeai.com
6
+ Project-URL: Repository, https://github.com/tsouth89/burnwatch-app
7
+ Project-URL: Documentation, https://github.com/tsouth89/burnwatch-app/blob/main/sdk/README.md
8
+ Requires-Python: >=3.9
@@ -0,0 +1,107 @@
1
+ # Burnwatch SDK (Python)
2
+
3
+ A thin, **stdlib-only** shim that mirrors your AI agent's payments to Burnwatch for spend
4
+ monitoring. Observe-only: it sends payment *metadata* (amount, recipient, resource, rail), never
5
+ your keys or funds, and it's **fail-open** — if Burnwatch is unreachable, your agent keeps paying.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install burnwatch
11
+ ```
12
+
13
+ Or directly from GitHub (before PyPI release):
14
+
15
+ ```bash
16
+ pip install "burnwatch @ git+https://github.com/tsouth89/burnwatch-app.git#subdirectory=sdk"
17
+ ```
18
+
19
+ ## Endpoint
20
+
21
+ Use your Burnwatch deployment base URL (no `/ingest` suffix — the client adds `/ingest/payments`):
22
+
23
+ - Production: `https://burnwatch.southforgeai.com`
24
+ - Local dev: `http://localhost:8010`
25
+
26
+ Create an ingest token in the dashboard (**Settings → Collector setup**) or via
27
+ `python -m scripts.make_token` on the backend.
28
+
29
+ ## Basic use
30
+
31
+ ```python
32
+ from burnwatch import BurnwatchClient
33
+
34
+ with BurnwatchClient(endpoint="https://burnwatch.southforgeai.com", token="bw_your_token") as bw:
35
+ bw.record(
36
+ agent_ref="agent_7f3c", # stable agent id
37
+ agent_name="research-bot", # optional; used when auto-provisioning
38
+ amount=0.002,
39
+ recipient="api.weather.dev", # payee / endpoint / address
40
+ resource="GET /forecast",
41
+ rail="x402",
42
+ currency="USDC",
43
+ context={"tx_hash": "0xabc...", "chain_id": "eip155:8453"}, # optional, public only
44
+ )
45
+ ```
46
+
47
+ Calls are **buffered and flushed in the background** (every `flush_interval` seconds, or when
48
+ `max_batch` is reached), so `record()` never blocks your agent. Set `enabled=False` to disable
49
+ mirroring in tests without changing call sites.
50
+
51
+ ## Context metadata
52
+
53
+ Pass optional `context` for dedup and richer detection. **Never** include private keys, mnemonics,
54
+ seeds, or raw signatures — the API returns **422** if forbidden keys are present.
55
+
56
+ | Key | Purpose |
57
+ |-----|---------|
58
+ | `tx_hash`, `payment_id`, `transaction_id` | Dedup (preferred over content hash) |
59
+ | `asset` | Token symbol when different from `currency` |
60
+ | `chain_id` | e.g. `eip155:8453` |
61
+ | `facilitator` | `coinbase`, self-hosted, etc. |
62
+ | `wallet_provider` | `cdp`, `wdk`, `agentkit`, etc. |
63
+ | `network` | `mainnet` or `testnet` |
64
+
65
+ ## x402 wrapper
66
+
67
+ Use `X402Monitor` (or `PaymentMirror` metadata) to wrap your existing x402 HTTP client:
68
+
69
+ ```python
70
+ from burnwatch import BurnwatchClient, X402Monitor
71
+
72
+ with BurnwatchClient(endpoint="https://burnwatch.southforgeai.com", token="bw_...") as bw:
73
+ mon = X402Monitor(bw, agent_ref="agent_7f3c", agent_name="research-bot")
74
+ resp = mon.paid_get(x402_client.get, "https://api.weather.dev/forecast", max_amount=0.01)
75
+ ```
76
+
77
+ Or mirror manually after your own client returns:
78
+
79
+ ```python
80
+ resp = x402_client.get(url, max_amount=price)
81
+ mon.after_payment(resp, recipient=url, resource="GET /forecast")
82
+ ```
83
+
84
+ See `examples/x402_wrapper.py` for a runnable demo with a fake x402 client.
85
+
86
+ ## Manual seam
87
+
88
+ If you prefer an explicit `record()` at the call site:
89
+
90
+ ```python
91
+ def paid_get(url, price, *, bw, agent_ref):
92
+ resp = x402_client.get(url, max_amount=price) # your real x402 call
93
+ bw.record(agent_ref=agent_ref, amount=resp.amount_paid, recipient=url, resource="GET")
94
+ return resp
95
+ ```
96
+
97
+ ## Examples
98
+
99
+ ```bash
100
+ BURNWATCH_ENDPOINT=http://localhost:8010 BURNWATCH_TOKEN=bw_... python examples/quickstart.py
101
+ ```
102
+
103
+ ## What Burnwatch detects
104
+
105
+ The backend runs 10 transparent rules after warm-up (velocity, unknown payees, drain bursts,
106
+ off-pattern destinations, amount spikes, off-hours spend, new rails, concentration, counterparty
107
+ velocity, asset anomalies). See the root [`README.md`](../README.md) for the full table.
@@ -0,0 +1,19 @@
1
+ """Burnwatch SDK — observe-only spend mirroring for AI agents.
2
+
3
+ Wrap your agent's payment client, call ``record()`` after each payment, and Burnwatch watches the
4
+ metadata for drains, overspends, and unknown counterparties. It never holds your keys or funds and
5
+ never sits in the payment path: mirroring is async, batched, and fail-open — if Burnwatch is
6
+ unreachable, your agent keeps paying as normal.
7
+
8
+ from burnwatch import BurnwatchClient
9
+
10
+ bw = BurnwatchClient(endpoint="https://burnwatch.southforgeai.com", token="bw_...")
11
+ # ... after your agent makes an x402 payment ...
12
+ bw.record(agent_ref="agent_7f3c", amount=0.002, recipient="api.weather.dev", resource="GET /forecast")
13
+ bw.close() # or use `with BurnwatchClient(...) as bw:`
14
+ """
15
+ from burnwatch.client import BurnwatchClient
16
+ from burnwatch.x402 import PaymentMirror, X402Monitor
17
+
18
+ __all__ = ["BurnwatchClient", "X402Monitor", "PaymentMirror"]
19
+ __version__ = "0.1.0"
@@ -0,0 +1,130 @@
1
+ """The Burnwatch client: buffer payment metadata and mirror it outbound, async and fail-open."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import threading
7
+ import urllib.request
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+ log = logging.getLogger("burnwatch")
12
+
13
+ __sdk_version__ = "0.1.0"
14
+
15
+
16
+ class BurnwatchClient:
17
+ """Mirrors agent payments to the Burnwatch backend.
18
+
19
+ Design rules (CLAUDE.md §3): outbound-only, metadata-only, never in the money path. Every
20
+ network operation swallows its errors — a monitoring failure must never break the agent.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ endpoint: str,
26
+ token: str,
27
+ *,
28
+ flush_interval: float = 2.0,
29
+ max_batch: int = 100,
30
+ timeout: float = 3.0,
31
+ enabled: bool = True,
32
+ ) -> None:
33
+ self._url = endpoint.rstrip("/") + "/ingest/payments"
34
+ self._token = token
35
+ self._flush_interval = flush_interval
36
+ self._max_batch = max_batch
37
+ self._timeout = timeout
38
+ self._enabled = enabled
39
+
40
+ self._buf: list[dict[str, Any]] = []
41
+ self._lock = threading.Lock()
42
+ self._stop = threading.Event()
43
+ self._thread: threading.Thread | None = None
44
+ if enabled:
45
+ self._thread = threading.Thread(target=self._loop, name="burnwatch-flush", daemon=True)
46
+ self._thread.start()
47
+
48
+ # -- public API -----------------------------------------------------------
49
+
50
+ def record(
51
+ self,
52
+ *,
53
+ agent_ref: str,
54
+ amount: float,
55
+ recipient: str,
56
+ resource: str | None = None,
57
+ currency: str = "USDC",
58
+ rail: str = "x402",
59
+ status: str = "paid",
60
+ ts: datetime | None = None,
61
+ agent_name: str | None = None,
62
+ context: dict[str, Any] | None = None,
63
+ ) -> None:
64
+ """Queue one payment for mirroring. Non-blocking; never raises."""
65
+ if not self._enabled:
66
+ return
67
+ event = {
68
+ "agent_ref": agent_ref,
69
+ "amount": amount,
70
+ "recipient": recipient,
71
+ "resource": resource,
72
+ "currency": currency,
73
+ "rail": rail,
74
+ "status": status,
75
+ "ts": (ts or datetime.now(timezone.utc)).isoformat(),
76
+ }
77
+ if agent_name:
78
+ event["agent_name"] = agent_name
79
+ if context:
80
+ event["context"] = context
81
+ with self._lock:
82
+ self._buf.append(event)
83
+ full = len(self._buf) >= self._max_batch
84
+ if full:
85
+ self.flush()
86
+
87
+ def flush(self) -> None:
88
+ """Send any buffered events now. Fail-open: network/HTTP errors are logged, not raised."""
89
+ with self._lock:
90
+ if not self._buf:
91
+ return
92
+ batch, self._buf = self._buf, []
93
+ try:
94
+ self._post({"events": batch, "sdk_version": __sdk_version__})
95
+ except Exception as exc: # noqa: BLE001 — monitoring must never break the caller
96
+ log.debug("burnwatch flush failed (%s events dropped): %s", len(batch), exc)
97
+
98
+ def close(self) -> None:
99
+ """Stop the background flusher and send anything still buffered."""
100
+ self._stop.set()
101
+ if self._thread is not None:
102
+ self._thread.join(timeout=self._timeout + 1)
103
+ self.flush()
104
+
105
+ def __enter__(self) -> "BurnwatchClient":
106
+ return self
107
+
108
+ def __exit__(self, *exc: Any) -> None:
109
+ self.close()
110
+
111
+ # -- internals ------------------------------------------------------------
112
+
113
+ def _loop(self) -> None:
114
+ while not self._stop.wait(self._flush_interval):
115
+ self.flush()
116
+
117
+ def _post(self, payload: dict[str, Any]) -> None:
118
+ req = urllib.request.Request(
119
+ self._url,
120
+ data=json.dumps(payload).encode("utf-8"),
121
+ headers={"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"},
122
+ method="POST",
123
+ )
124
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
125
+ if resp.status >= 400:
126
+ log.debug("burnwatch ingest returned %s", resp.status)
127
+
128
+
129
+ # silence "no handler" warnings while staying quiet by default
130
+ log.addHandler(logging.NullHandler())
@@ -0,0 +1,163 @@
1
+ """x402 payment wrapper — one-line integration over any paid HTTP client.
2
+
3
+ Burnwatch does not ship an x402 transport; this module wraps *your* client and mirrors metadata
4
+ after each successful payment. Fail-open: recording errors never propagate to the caller.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Callable
10
+
11
+ from burnwatch.client import BurnwatchClient
12
+
13
+
14
+ def _extract_amount(result: Any, attr: str, fallback: float | None) -> float:
15
+ if isinstance(result, dict):
16
+ val = result.get(attr, result.get("amount"))
17
+ else:
18
+ val = getattr(result, attr, None)
19
+ if val is None:
20
+ val = getattr(result, "amount", None)
21
+ if val is None:
22
+ if fallback is None:
23
+ return 0.0
24
+ return float(fallback)
25
+ return float(val)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PaymentMirror:
30
+ """Normalized payment metadata passed to Burnwatch after an x402 call."""
31
+
32
+ amount: float
33
+ recipient: str
34
+ resource: str | None = None
35
+ currency: str = "USDC"
36
+ rail: str = "x402"
37
+ status: str = "paid"
38
+ context: dict[str, Any] | None = None
39
+
40
+
41
+ class X402Monitor:
42
+ """Wrap paid x402 calls and auto-mirror metadata to Burnwatch.
43
+
44
+ Example::
45
+
46
+ bw = BurnwatchClient(endpoint="https://burnwatch.example.com", token="bw_...")
47
+ mon = X402Monitor(bw, agent_ref="agent_7f3c", agent_name="research-bot")
48
+
49
+ # pass your real x402 client's get function
50
+ resp = mon.paid_get(x402_client.get, "https://api.weather.dev/forecast", max_amount=0.01)
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ client: BurnwatchClient,
56
+ *,
57
+ agent_ref: str,
58
+ agent_name: str | None = None,
59
+ ) -> None:
60
+ self._client = client
61
+ self._agent_ref = agent_ref
62
+ self._agent_name = agent_name
63
+
64
+ def mirror(self, payment: PaymentMirror) -> None:
65
+ """Record a completed payment explicitly."""
66
+ self._client.record(
67
+ agent_ref=self._agent_ref,
68
+ agent_name=self._agent_name,
69
+ amount=payment.amount,
70
+ recipient=payment.recipient,
71
+ resource=payment.resource,
72
+ currency=payment.currency,
73
+ rail=payment.rail,
74
+ status=payment.status,
75
+ context=payment.context,
76
+ )
77
+
78
+ def after_payment(
79
+ self,
80
+ result: Any,
81
+ *,
82
+ recipient: str,
83
+ resource: str | None = None,
84
+ amount_attr: str = "amount_paid",
85
+ amount_fallback: float | None = None,
86
+ context: dict[str, Any] | None = None,
87
+ ) -> Any:
88
+ """Mirror an already-completed payment; returns ``result`` unchanged."""
89
+ self.mirror(
90
+ PaymentMirror(
91
+ amount=_extract_amount(result, amount_attr, amount_fallback),
92
+ recipient=recipient,
93
+ resource=resource,
94
+ context=context,
95
+ )
96
+ )
97
+ return result
98
+
99
+ def paid_call(
100
+ self,
101
+ pay_fn: Callable[..., Any],
102
+ recipient: str,
103
+ *,
104
+ resource: str | None = None,
105
+ amount_attr: str = "amount_paid",
106
+ amount_fallback: float | None = None,
107
+ context: dict[str, Any] | None = None,
108
+ **pay_kwargs: Any,
109
+ ) -> Any:
110
+ """Call ``pay_fn(**pay_kwargs)``, mirror metadata, return the result."""
111
+ result = pay_fn(**pay_kwargs)
112
+ return self.after_payment(
113
+ result,
114
+ recipient=recipient,
115
+ resource=resource,
116
+ amount_attr=amount_attr,
117
+ amount_fallback=amount_fallback,
118
+ context=context,
119
+ )
120
+
121
+ def paid_get(
122
+ self,
123
+ get_fn: Callable[..., Any],
124
+ url: str,
125
+ *,
126
+ max_amount: float | None = None,
127
+ resource: str | None = None,
128
+ amount_attr: str = "amount_paid",
129
+ **get_kwargs: Any,
130
+ ) -> Any:
131
+ """Common x402 GET pattern: ``get_fn(url, max_amount=...)`` then mirror."""
132
+ kwargs = dict(get_kwargs)
133
+ if max_amount is not None and "max_amount" not in kwargs:
134
+ kwargs["max_amount"] = max_amount
135
+ return self.paid_call(
136
+ lambda: get_fn(url, **kwargs),
137
+ url,
138
+ resource=resource or f"GET {url}",
139
+ amount_attr=amount_attr,
140
+ amount_fallback=max_amount,
141
+ )
142
+
143
+ def paid_post(
144
+ self,
145
+ post_fn: Callable[..., Any],
146
+ url: str,
147
+ *,
148
+ max_amount: float | None = None,
149
+ resource: str | None = None,
150
+ amount_attr: str = "amount_paid",
151
+ **post_kwargs: Any,
152
+ ) -> Any:
153
+ """Common x402 POST pattern: ``post_fn(url, max_amount=...)`` then mirror."""
154
+ kwargs = dict(post_kwargs)
155
+ if max_amount is not None and "max_amount" not in kwargs:
156
+ kwargs["max_amount"] = max_amount
157
+ return self.paid_call(
158
+ lambda: post_fn(url, **kwargs),
159
+ url,
160
+ resource=resource or f"POST {url}",
161
+ amount_attr=amount_attr,
162
+ amount_fallback=max_amount,
163
+ )
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: burnwatch
3
+ Version: 0.1.0
4
+ Summary: Burnwatch SDK — mirror your AI agent's payments to Burnwatch for spend monitoring.
5
+ Project-URL: Homepage, https://getburnwatch.southforgeai.com
6
+ Project-URL: Repository, https://github.com/tsouth89/burnwatch-app
7
+ Project-URL: Documentation, https://github.com/tsouth89/burnwatch-app/blob/main/sdk/README.md
8
+ Requires-Python: >=3.9
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ burnwatch/__init__.py
4
+ burnwatch/client.py
5
+ burnwatch/x402.py
6
+ burnwatch.egg-info/PKG-INFO
7
+ burnwatch.egg-info/SOURCES.txt
8
+ burnwatch.egg-info/dependency_links.txt
9
+ burnwatch.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ burnwatch
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "burnwatch"
3
+ version = "0.1.0"
4
+ description = "Burnwatch SDK — mirror your AI agent's payments to Burnwatch for spend monitoring."
5
+ requires-python = ">=3.9"
6
+ dependencies = [] # stdlib-only on purpose: a monitor must never add weight or break your agent
7
+
8
+ [build-system]
9
+ requires = ["setuptools>=70"]
10
+ build-backend = "setuptools.build_meta"
11
+
12
+ [project.urls]
13
+ Homepage = "https://getburnwatch.southforgeai.com"
14
+ Repository = "https://github.com/tsouth89/burnwatch-app"
15
+ Documentation = "https://github.com/tsouth89/burnwatch-app/blob/main/sdk/README.md"
16
+
17
+ [tool.setuptools.packages.find]
18
+ include = ["burnwatch*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+