spendwall 0.0.6__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.
spendwall/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Spendwall — local-first agent spending firewall."""
2
+ __version__ = "0.0.2"
3
+
4
+ from spendwall.client import SpendwallClient # noqa: F401
5
+ from spendwall.errors import ( # noqa: F401
6
+ ApprovalRequiredError,
7
+ BlockedError,
8
+ SpendwallError,
9
+ SpendwallVersionMismatch,
10
+ )
11
+ from spendwall.modes import Mode # noqa: F401
spendwall/anthropic.py ADDED
@@ -0,0 +1,88 @@
1
+ """Wrapper for ``anthropic.Anthropic`` clients.
2
+
3
+ Patches ``client.messages.create`` in-place so every upstream call is
4
+ observed by spendwalld. In ``enforce`` mode, the wrapper consults policy
5
+ *before* dispatching to the upstream and raises
6
+ ``BlockedError``/``ApprovalRequiredError`` when the daemon refuses.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json as _json
12
+ from typing import Any, Optional
13
+
14
+ from spendwall.client import SpendwallClient
15
+ from spendwall.modes import Mode, resolve as resolve_mode
16
+
17
+
18
+ def _estimate_tokens(messages_or_input: Any) -> int:
19
+ try:
20
+ return max(1, len(_json.dumps(messages_or_input)) // 4)
21
+ except (TypeError, ValueError):
22
+ return 1
23
+
24
+
25
+ def wrap(
26
+ anthropic_client: Any,
27
+ *,
28
+ sw_client: Optional[SpendwallClient] = None,
29
+ agent_id: str = "anonymous",
30
+ mode: Optional[Mode] = None,
31
+ tags: Optional[dict[str, str]] = None,
32
+ ) -> Any:
33
+ """Patch ``anthropic_client.messages.create`` to call into spendwall
34
+ before/after the upstream request.
35
+
36
+ Mutates the supplied client in-place AND returns it for ergonomic chaining:
37
+
38
+ client = wrap(anthropic.Anthropic(...), agent_id="x")
39
+ """
40
+ sw = sw_client or SpendwallClient()
41
+ configured_mode = mode
42
+
43
+ original = anthropic_client.messages.create
44
+
45
+ def patched(*args: Any, **kwargs: Any) -> Any:
46
+ call_mode = resolve_mode(configured=configured_mode)
47
+ model = kwargs.get("model", "unknown")
48
+ est_input_tokens = _estimate_tokens(kwargs.get("messages"))
49
+
50
+ if call_mode == "enforce":
51
+ sw.policy_check_strict(
52
+ agent_id=agent_id, provider="anthropic", model=model,
53
+ estimated_cost_cents=0, tags=tags or {},
54
+ )
55
+ elif call_mode == "advisory":
56
+ sw.policy_check(
57
+ agent_id=agent_id, provider="anthropic", model=model,
58
+ estimated_cost_cents=0, tags=tags or {},
59
+ )
60
+ # observe-mode skips policy_check entirely.
61
+ try:
62
+ result = original(*args, **kwargs)
63
+ except Exception:
64
+ sw.observe({
65
+ "agent_id": agent_id, "provider": "anthropic", "model": model,
66
+ "source": "sdk_python", "input_tokens": est_input_tokens,
67
+ "output_tokens": None, "estimated_cost_cents": 0,
68
+ "actual_cost_cents": None, "decision_str": "allowed",
69
+ "binding_caps": [], "tags": tags or {},
70
+ "outcome": "upstream_error",
71
+ })
72
+ raise
73
+
74
+ usage = getattr(result, "usage", None)
75
+ input_tokens = getattr(usage, "input_tokens", None) if usage else None
76
+ output_tokens = getattr(usage, "output_tokens", None) if usage else None
77
+ sw.observe({
78
+ "agent_id": agent_id, "provider": "anthropic", "model": model,
79
+ "source": "sdk_python", "input_tokens": input_tokens,
80
+ "output_tokens": output_tokens, "estimated_cost_cents": 0,
81
+ "actual_cost_cents": None, "decision_str": "allowed",
82
+ "binding_caps": [], "tags": tags or {},
83
+ "outcome": "success",
84
+ })
85
+ return result
86
+
87
+ anthropic_client.messages.create = patched
88
+ return anthropic_client
spendwall/client.py ADDED
@@ -0,0 +1,118 @@
1
+ """UDS HTTP client for spendwalld."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Optional
7
+ from urllib.parse import quote
8
+
9
+ import requests
10
+ import requests_unixsocket
11
+
12
+ from spendwall.errors import ApprovalRequiredError, BlockedError
13
+
14
+
15
+ def _default_socket_path() -> str:
16
+ return os.environ.get(
17
+ "SPENDWALL_SOCKET",
18
+ os.path.expanduser("~/.spendwall/spendwall.sock"),
19
+ )
20
+
21
+
22
+ def _default_bearer() -> Optional[str]:
23
+ return os.environ.get("SPENDWALL_TOKEN")
24
+
25
+
26
+ class SpendwallClient:
27
+ """Talks to the spendwall daemon over its UDS HTTP API.
28
+
29
+ Fail-open: when ``fail_open=True`` and the socket is unreachable, ``observe()``
30
+ is silently dropped and ``policy_check()`` returns a synthetic "allowed"
31
+ decision. Default is ``fail_open=True`` because a spending firewall MUST NOT
32
+ bring down user agents when the daemon is down.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ socket_path: Optional[str] = None,
38
+ bearer: Optional[str] = None,
39
+ timeout_seconds: float = 2.0,
40
+ fail_open: bool = True,
41
+ ):
42
+ self.socket_path = socket_path or _default_socket_path()
43
+ self.bearer = bearer or _default_bearer()
44
+ self.timeout_seconds = timeout_seconds
45
+ self.fail_open = fail_open
46
+ self._session = requests_unixsocket.Session()
47
+ self._base = f"http+unix://{quote(self.socket_path, safe='')}"
48
+
49
+ def _headers(self) -> dict[str, str]:
50
+ h = {"content-type": "application/json"}
51
+ if self.bearer:
52
+ h["authorization"] = f"Bearer {self.bearer}"
53
+ return h
54
+
55
+ def policy_check(
56
+ self,
57
+ *,
58
+ agent_id: str,
59
+ provider: str,
60
+ model: str,
61
+ estimated_cost_cents: int,
62
+ tags: Optional[dict[str, str]] = None,
63
+ ) -> dict[str, Any]:
64
+ body = {
65
+ "agent_id": agent_id,
66
+ "provider": provider,
67
+ "model": model,
68
+ "estimated_cost_cents": estimated_cost_cents,
69
+ "tags": tags or {},
70
+ }
71
+ try:
72
+ r = self._session.post(
73
+ f"{self._base}/v1/policy/check",
74
+ json=body,
75
+ headers=self._headers(),
76
+ timeout=self.timeout_seconds,
77
+ )
78
+ r.raise_for_status()
79
+ return r.json()
80
+ except (requests.RequestException, ValueError):
81
+ if self.fail_open:
82
+ return {"decision": "allowed", "alert_caps": []}
83
+ raise
84
+
85
+ def policy_check_strict(self, **kwargs: Any) -> dict[str, Any]:
86
+ """Like ``policy_check`` but raises ``BlockedError`` /
87
+ ``ApprovalRequiredError`` on non-allowed decisions."""
88
+ d = self.policy_check(**kwargs)
89
+ if d.get("decision") == "blocked":
90
+ raise BlockedError(
91
+ cap_id=d.get("cap_id", "?"),
92
+ projected_cents=d.get("projected_cents", 0),
93
+ limit_cents=d.get("limit_cents", 0),
94
+ )
95
+ if d.get("decision") == "approval_required":
96
+ raise ApprovalRequiredError(approval_caps=d.get("approval_caps", []))
97
+ return d
98
+
99
+ def observe(self, event: dict[str, Any]) -> None:
100
+ try:
101
+ self._session.post(
102
+ f"{self._base}/v1/spend/observe",
103
+ json=event,
104
+ headers=self._headers(),
105
+ timeout=self.timeout_seconds,
106
+ )
107
+ except requests.RequestException:
108
+ if not self.fail_open:
109
+ raise
110
+ # Fail-open: drop on the floor.
111
+
112
+ def about(self) -> dict[str, Any]:
113
+ r = self._session.get(
114
+ f"{self._base}/v1/about",
115
+ timeout=self.timeout_seconds,
116
+ )
117
+ r.raise_for_status()
118
+ return r.json()
spendwall/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Spendwall SDK exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SpendwallError(Exception):
7
+ """Base for all spendwall-raised exceptions."""
8
+
9
+
10
+ class BlockedError(SpendwallError):
11
+ """Raised in `enforce` mode when policy returned Blocked."""
12
+
13
+ def __init__(self, cap_id: str, projected_cents: int, limit_cents: int):
14
+ self.cap_id = cap_id
15
+ self.projected_cents = projected_cents
16
+ self.limit_cents = limit_cents
17
+ super().__init__(
18
+ f"Spendwall blocked by cap '{cap_id}': "
19
+ f"projected {projected_cents}c > limit {limit_cents}c"
20
+ )
21
+
22
+
23
+ class ApprovalRequiredError(SpendwallError):
24
+ """Raised in `enforce` mode when policy returned ApprovalRequired and the
25
+ approval was denied or timed out."""
26
+
27
+ def __init__(self, approval_caps: list[str], reason: str = "denied"):
28
+ self.approval_caps = approval_caps
29
+ self.reason = reason
30
+ super().__init__(
31
+ f"Spendwall approval {reason} for caps={approval_caps}"
32
+ )
33
+
34
+
35
+ class SpendwallVersionMismatch(SpendwallError):
36
+ """Raised once when the daemon's API version does not match the SDK's.
37
+ SDK falls back to fail-open `observe` mode for the rest of the process."""
spendwall/modes.py ADDED
@@ -0,0 +1,26 @@
1
+ """Mode resolution — observe, advisory, enforce."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Literal, Optional
7
+
8
+ Mode = Literal["observe", "advisory", "enforce"]
9
+ _VALID = ("observe", "advisory", "enforce")
10
+ _ENV_VAR = "SPENDWALL_MODE"
11
+
12
+
13
+ def resolve(per_call: Optional[str] = None, configured: Optional[str] = None) -> Mode:
14
+ """Precedence (most specific wins):
15
+ 1. `per_call` kwarg passed by user code at the call site
16
+ 2. `configured` value from `wrap(..., mode=...)`
17
+ 3. `SPENDWALL_MODE` env var
18
+ 4. Default: `observe`
19
+ """
20
+ for v in (per_call, configured, os.environ.get(_ENV_VAR)):
21
+ if v is None:
22
+ continue
23
+ if v not in _VALID:
24
+ raise ValueError(f"invalid spendwall mode: {v!r}; expected one of {_VALID}")
25
+ return v # type: ignore[return-value]
26
+ return "observe"
spendwall/openai.py ADDED
@@ -0,0 +1,98 @@
1
+ """Wrapper for ``openai.OpenAI`` clients.
2
+
3
+ Patches ``client.chat.completions.create`` and ``client.responses.create``
4
+ in-place so every upstream call is observed by spendwalld. In ``enforce``
5
+ mode, the wrapper consults policy *before* dispatching to the upstream and
6
+ raises ``BlockedError``/``ApprovalRequiredError`` when the daemon refuses.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json as _json
12
+ from typing import Any, Optional
13
+
14
+ from spendwall.client import SpendwallClient
15
+ from spendwall.modes import Mode, resolve as resolve_mode
16
+
17
+
18
+ def _estimate_tokens(messages_or_input: Any) -> int:
19
+ """Cheap byte-based estimate; daemon reconciles cost post-flight."""
20
+ try:
21
+ return max(1, len(_json.dumps(messages_or_input)) // 4)
22
+ except (TypeError, ValueError):
23
+ return 1
24
+
25
+
26
+ def wrap(
27
+ openai_client: Any,
28
+ *,
29
+ sw_client: Optional[SpendwallClient] = None,
30
+ agent_id: str = "anonymous",
31
+ mode: Optional[Mode] = None,
32
+ tags: Optional[dict[str, str]] = None,
33
+ ) -> Any:
34
+ """Patch ``openai_client.chat.completions.create`` (and ``.responses.create``)
35
+ to call into spendwall before/after the upstream request.
36
+
37
+ Mutates the supplied client in-place AND returns it for ergonomic chaining:
38
+
39
+ client = wrap(openai.OpenAI(...), agent_id="x")
40
+ """
41
+ sw = sw_client or SpendwallClient()
42
+ configured_mode = mode
43
+
44
+ def _patch(parent: Any, name: str) -> None:
45
+ original = getattr(parent, name)
46
+
47
+ def patched(*args: Any, **kwargs: Any) -> Any:
48
+ call_mode = resolve_mode(configured=configured_mode)
49
+ model = kwargs.get("model", "unknown")
50
+ est_input_tokens = _estimate_tokens(
51
+ kwargs.get("messages") or kwargs.get("input")
52
+ )
53
+ # Daemon does pricing lookup; SDK sends estimated_cost_cents=0
54
+ # to defer cost estimation to the daemon side.
55
+ if call_mode == "enforce":
56
+ sw.policy_check_strict(
57
+ agent_id=agent_id, provider="openai", model=model,
58
+ estimated_cost_cents=0, tags=tags or {},
59
+ )
60
+ elif call_mode == "advisory":
61
+ sw.policy_check(
62
+ agent_id=agent_id, provider="openai", model=model,
63
+ estimated_cost_cents=0, tags=tags or {},
64
+ )
65
+ # observe-mode skips policy_check entirely (zero added latency).
66
+ try:
67
+ result = original(*args, **kwargs)
68
+ except Exception:
69
+ sw.observe({
70
+ "agent_id": agent_id, "provider": "openai", "model": model,
71
+ "source": "sdk_python", "input_tokens": est_input_tokens,
72
+ "output_tokens": None, "estimated_cost_cents": 0,
73
+ "actual_cost_cents": None, "decision_str": "allowed",
74
+ "binding_caps": [], "tags": tags or {},
75
+ "outcome": "upstream_error",
76
+ })
77
+ raise
78
+
79
+ usage = getattr(result, "usage", None)
80
+ input_tokens = getattr(usage, "prompt_tokens", None) if usage else None
81
+ output_tokens = getattr(usage, "completion_tokens", None) if usage else None
82
+ sw.observe({
83
+ "agent_id": agent_id, "provider": "openai", "model": model,
84
+ "source": "sdk_python", "input_tokens": input_tokens,
85
+ "output_tokens": output_tokens, "estimated_cost_cents": 0,
86
+ "actual_cost_cents": None, "decision_str": "allowed",
87
+ "binding_caps": [], "tags": tags or {},
88
+ "outcome": "success",
89
+ })
90
+ return result
91
+
92
+ setattr(parent, name, patched)
93
+
94
+ if hasattr(openai_client, "chat") and hasattr(openai_client.chat, "completions"):
95
+ _patch(openai_client.chat.completions, "create")
96
+ if hasattr(openai_client, "responses"):
97
+ _patch(openai_client.responses, "create")
98
+ return openai_client
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: spendwall
3
+ Version: 0.0.6
4
+ Summary: Spendwall SDK — local-first agent spend firewall
5
+ Author: Spendwall
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests-unixsocket>=0.3
10
+ Requires-Dist: requests>=2.28
11
+ Provides-Extra: openai
12
+ Requires-Dist: openai>=1.0; extra == "openai"
13
+ Provides-Extra: anthropic
14
+ Requires-Dist: anthropic>=0.21; extra == "anthropic"
15
+ Provides-Extra: all
16
+ Requires-Dist: openai>=1.0; extra == "all"
17
+ Requires-Dist: anthropic>=0.21; extra == "all"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == "dev"
20
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
21
+ Requires-Dist: responses>=0.23; extra == "dev"
22
+
23
+ # spendwall (Python)
24
+
25
+ ```python
26
+ import openai
27
+ import spendwall.openai as sw
28
+
29
+ client = openai.OpenAI(api_key="sk-...")
30
+ client = sw.wrap(client, mode="enforce")
31
+
32
+ # Now every client.chat.completions.create(...) is observed by spendwalld.
33
+ ```
@@ -0,0 +1,10 @@
1
+ spendwall/__init__.py,sha256=3wFQ_DwCWW1VNLsMKG1Nnnuf4BalHj3CUh1rFdoPgX8,328
2
+ spendwall/anthropic.py,sha256=uHQmwXrSr7VLSIRNdo_GEkQvuJYGuzZbh6rnOa67yf4,3219
3
+ spendwall/client.py,sha256=YfjrgQM1lbuqRLL80Rf2-SjfeSMACxX-JNE35NHDiFo,3756
4
+ spendwall/errors.py,sha256=DuBdVvo8e7tnXim6HVZlHPT-byf_8kerBFL6FNG42TI,1228
5
+ spendwall/modes.py,sha256=-YHg3q90HjQdBqD5i17kyNUeAVXN9aJwuVwuAGdOSsQ,860
6
+ spendwall/openai.py,sha256=0viGVSZ-5fdVDUQBNQXrrhowOmdqyKIZ-rUuePXrn3g,3974
7
+ spendwall-0.0.6.dist-info/METADATA,sha256=z1b3UBj5-K5_yySHhfyeBSbdSk5o2ZCj7FP_xFm0EkA,943
8
+ spendwall-0.0.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ spendwall-0.0.6.dist-info/top_level.txt,sha256=Dk33kymC3JHwUjo8dO_m55Qja7dyRtU6Q4AKLwxugdU,10
10
+ spendwall-0.0.6.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ spendwall