oceanalt-agentkit-hook 0.1.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,184 @@
1
+ """oceanalt_agentkit_hook 0.1.0. Sync before-payment hook for Coinbase AgentKit (Python).
2
+
3
+ Works with:
4
+ * coinbase/agentkit#1577: X402Config(before_payment=...) -> x402ClientSync.on_before_payment_creation.
5
+ Context is x402's PaymentCreationContext; we return AbortResult to block, None to allow.
6
+ * a dict context shaped like #1454 / #1613 (url, payTo, network, asset, amount, ...);
7
+ we return {"abort": True, "reason": ..., "code": ...} to block, None to allow.
8
+ Never return a dict or a coroutine to the x402 SDK: it only blocks on isinstance(result, AbortResult).
9
+ Not affiliated with Coinbase.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Callable
16
+ from typing import Any, Literal
17
+ from urllib.parse import urlsplit
18
+
19
+ import httpx # inside the agentkit repo itself, use `requests` instead (it is already a dependency)
20
+
21
+ try: # x402 SDK is optional; only needed for the #1577 context
22
+ from x402.schemas.hooks import AbortResult # type: ignore
23
+ except Exception: # pragma: no cover
24
+ AbortResult = None # type: ignore
25
+
26
+ Policy = Literal["block", "allow"]
27
+ _EVM = re.compile(r"^0x[0-9a-fA-F]{40}$")
28
+ _V1 = {
29
+ "base": "eip155:8453", "base-sepolia": "eip155:84532", "ethereum": "eip155:1",
30
+ "polygon": "eip155:137", "arbitrum": "eip155:42161", "optimism": "eip155:10",
31
+ "avalanche": "eip155:43114", "solana": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
32
+ }
33
+
34
+
35
+ def _normalize(ctx: Any) -> tuple[dict[str, Any], bool]:
36
+ """Return (normalized fields, is_sdk_context)."""
37
+ sel = getattr(ctx, "selected_requirements", None)
38
+ if sel is not None:
39
+ pr = getattr(ctx, "payment_required", None)
40
+ resource = getattr(pr, "resource", None)
41
+ url = getattr(resource, "url", None) or getattr(sel, "resource", None) # v1 requirements carry resource
42
+ return {
43
+ "url": url,
44
+ "payTo": getattr(sel, "pay_to", None),
45
+ "network": getattr(sel, "network", None),
46
+ "asset": getattr(sel, "asset", None),
47
+ "amount": getattr(sel, "amount", None) or getattr(sel, "max_amount_required", None),
48
+ }, True
49
+ c = ctx if isinstance(ctx, dict) else {}
50
+ return {k: c.get(k) for k in ("url", "payTo", "network", "asset", "amount")}, False
51
+
52
+
53
+ def _caip2(n: str | None) -> str | None:
54
+ if not n:
55
+ return None
56
+ return n if ":" in n else _V1.get(n.lower(), n)
57
+
58
+
59
+ def _strip(u: str | None) -> str | None:
60
+ if not u:
61
+ return None
62
+ p = urlsplit(u)
63
+ if p.scheme not in ("http", "https") or not p.netloc:
64
+ return None
65
+ return f"{p.scheme}://{p.netloc}{p.path}"[:2048]
66
+
67
+
68
+ def _decide_body(n: dict[str, Any], send_ref: bool) -> dict[str, Any]:
69
+ net, pay_to, asset, amount = _caip2(n["network"]), n["payTo"], n["asset"], n["amount"]
70
+ ref = _strip(n["url"]) if send_ref else None
71
+ if (net and net.startswith("eip155:") and pay_to and _EVM.match(pay_to)
72
+ and asset and _EVM.match(asset) and isinstance(amount, str) and amount.isdigit()):
73
+ intent = {"v": "1", "payee": pay_to, "amount_atomic": amount,
74
+ "asset_id": f"{net}/erc20:{asset}", "network": net}
75
+ if ref:
76
+ intent["resource_ref"] = ref
77
+ return {"payment_intent": intent}
78
+ body: dict[str, Any] = {"to": pay_to}
79
+ if net:
80
+ body["network"] = net
81
+ if isinstance(amount, str) and re.fullmatch(r"\$\d+(\.\d+)?", amount):
82
+ body["amountUsdc"] = float(amount[1:])
83
+ return body
84
+
85
+
86
+ def oceanalt_before_payment(
87
+ *,
88
+ endpoint: Literal["decide", "x402-decision", "x402-payee"] = "decide",
89
+ base_url: str = "https://oceanalt.com",
90
+ api_key: str | None = None,
91
+ paid_client: httpx.Client | None = None, # an httpx client wrapped with x402 payment, for x402-* endpoints
92
+ on_review: Policy = "block",
93
+ on_error: Policy = "block",
94
+ on_missing_pay_to: Policy = "block",
95
+ timeout_s: float = 3.0,
96
+ code_prefix: str = "oceanalt:",
97
+ send_resource_ref: bool = True,
98
+ on_decision: Callable[[dict[str, Any]], None] | None = None,
99
+ ) -> Callable[[Any], Any]:
100
+ paid = endpoint != "decide"
101
+ if paid and paid_client is None:
102
+ raise ValueError(f"oceanalt_before_payment: endpoint {endpoint} needs paid_client")
103
+ client = paid_client or httpx.Client(timeout=timeout_s)
104
+ path = {"decide": "/api/decide", "x402-decision": "/api/x402/decision", "x402-payee": "/api/x402/payee"}[endpoint]
105
+ headers = {"content-type": "application/json", "user-agent": "oceanalt-agentkit-hook-py/0.1.0"}
106
+ if api_key:
107
+ headers["authorization"] = f"Bearer {api_key}"
108
+
109
+ def hook(ctx: Any) -> Any:
110
+ n, sdk = _normalize(ctx)
111
+
112
+ def emit(outcome: str, decision: str, code: str, response: Any = None) -> None:
113
+ if on_decision:
114
+ try:
115
+ on_decision({"outcome": outcome, "decision": decision, "code": code,
116
+ "payTo": n["payTo"], "network": n["network"],
117
+ "url": _strip(n["url"]), "response": response})
118
+ except Exception:
119
+ pass # logging must never change the decision
120
+
121
+ def stop(decision: str, code: str, reason: str, response: Any = None) -> Any:
122
+ full, text = code_prefix + code, f"[OceanAlt] {reason}"[:300]
123
+ emit("aborted", decision, full, response)
124
+ if sdk:
125
+ if AbortResult is None:
126
+ raise RuntimeError(text) # SDK context without AbortResult import: raising still blocks
127
+ return AbortResult(reason=full, message=text)
128
+ return {"abort": True, "reason": text, "code": full}
129
+
130
+ def allow(decision: str, code: str, response: Any = None) -> None:
131
+ emit("allowed", decision, code_prefix + code, response)
132
+ return None
133
+
134
+ def unavailable(code: str, detail: str) -> Any:
135
+ if on_error == "allow":
136
+ return allow("unavailable", code)
137
+ return stop("unavailable", code, f"decision unavailable ({detail}); payment not signed")
138
+
139
+ if not n["payTo"]:
140
+ if on_missing_pay_to == "allow":
141
+ return allow("missing_pay_to", "payee.missing")
142
+ return stop("missing_pay_to", "payee.missing", "the selected payment option declares no payTo")
143
+
144
+ if endpoint == "x402-payee":
145
+ body: dict[str, Any] = {"pay_to": n["payTo"]}
146
+ if _caip2(n["network"]):
147
+ body["network"] = _caip2(n["network"])
148
+ if send_resource_ref and _strip(n["url"]):
149
+ body["resource_url"] = _strip(n["url"])
150
+ else:
151
+ body = _decide_body(n, send_resource_ref)
152
+
153
+ try:
154
+ r = client.post(base_url.rstrip("/") + path, json=body, headers=headers, timeout=timeout_s)
155
+ except httpx.TimeoutException:
156
+ return unavailable("unavailable.timeout", f"no answer within {timeout_s}s")
157
+ except httpx.HTTPError:
158
+ return unavailable("unavailable.network", "network error")
159
+ if r.status_code >= 400:
160
+ if r.status_code == 402 and not paid:
161
+ return unavailable("unavailable.quota", "free allowance used up; set api_key")
162
+ return unavailable(f"unavailable.http_{r.status_code}", f"HTTP {r.status_code}")
163
+ try:
164
+ j = r.json()
165
+ except ValueError:
166
+ return unavailable("unavailable.bad_json", "unreadable response")
167
+
168
+ raw = j.get("decision") if isinstance(j, dict) else None
169
+ d = raw if raw in ("allow", "decline") else "review"
170
+ if endpoint == "x402-payee":
171
+ reasons = j.get("reasons") or []
172
+ rc = reasons[0].get("code") if reasons and isinstance(reasons[0], dict) else None
173
+ else:
174
+ rc = j.get("reason_code")
175
+ rc = rc or (d if raw == d else "unrecognized_decision")
176
+ summary = j.get("summary") if isinstance(j.get("summary"), str) else f"payee {n['payTo']} {d}"
177
+
178
+ if d == "allow":
179
+ return allow("allow", rc, j)
180
+ if d == "decline":
181
+ return stop("decline", rc, summary, j)
182
+ return allow("review", rc, j) if on_review == "allow" else stop("review", rc, summary, j)
183
+
184
+ return hook
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: oceanalt-agentkit-hook
3
+ Version: 0.1.0
4
+ Summary: Sync before-payment check for Coinbase AgentKit x402 payments (AgentKit #1577 / x402 SDK on_before_payment_creation, and #1454-shaped dicts). Fail-closed by default. Not affiliated with Coinbase.
5
+ License: MIT
6
+ Project-URL: Homepage, https://oceanalt.com/en/api-docs
7
+ Keywords: agentkit,x402,before_payment,agent-payments,aml
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: httpx>=0.25
11
+
12
+ # oceanalt-agentkit-hook (Python)
13
+
14
+ A synchronous before-payment check for Coinbase AgentKit x402 payments. Before anything is signed, it asks OceanAlt for a decision on the payee: `allow`, `review` or `decline`.
15
+
16
+ - With the x402 SDK hook (`on_before_payment_creation`, used by AgentKit #1577) it returns `AbortResult` to block and `None` to allow. The SDK only blocks on `AbortResult`; a returned dict or a coroutine would let the payment through, so this package never returns those to the SDK.
17
+ - With a dict context shaped like AgentKit #1454 it returns `{"abort": True, "reason": ..., "code": ...}` to block and `None` to allow.
18
+
19
+ Defaults are fail-closed: `review`, an unreachable service, a timeout (3 s) and a missing `payTo` all block. Change with `on_review`, `on_error`, `on_missing_pay_to`, `timeout_s`.
20
+
21
+ ```python
22
+ from oceanalt_agentkit_hook import oceanalt_before_payment
23
+
24
+ client.on_before_payment_creation(oceanalt_before_payment())
25
+ ```
26
+
27
+ It calls the free `POST https://oceanalt.com/api/decide`, sending the payee, network, asset, amount and the paid URL without its query string, so the service can also check the payee against that origin's own quote. Without a key the free endpoint allows 2,000 calls a month per source, then answers 402, which blocks payments under the default `on_error="block"`. For production agents, get a free key at https://oceanalt.com/en/api-keys (email only, 5,000 calls a month) and pass it as `api_key`. OceanAlt holds no keys and moves no money. Not affiliated with Coinbase. MIT.
@@ -0,0 +1,5 @@
1
+ oceanalt_agentkit_hook/__init__.py,sha256=2Qr8yzMoRE6sPT8KDjcmbGnkz3EZdbWsW_AuyiEkVvE,8196
2
+ oceanalt_agentkit_hook-0.1.0.dist-info/METADATA,sha256=gImu_YOGFHT7Y-G-cF0KhWPQd07G4SRqGW8QjG613Cg,2017
3
+ oceanalt_agentkit_hook-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
4
+ oceanalt_agentkit_hook-0.1.0.dist-info/top_level.txt,sha256=TiWy8LvT6Rl3PlS1uB6ukmnUcx_51ZeJVu7_e_jUCLM,23
5
+ oceanalt_agentkit_hook-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ oceanalt_agentkit_hook