2sio 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.
2sio-0.1.0/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ node_modules
2
+ .next
3
+ .vercel
4
+ out
5
+ .DS_Store
6
+ .env
7
+ .env.local
8
+ .data
9
+ *.tsbuildinfo
10
+ next-env.d.ts
11
+ .env*.local
12
+ .claude/settings.local.json
2sio-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: 2sio
3
+ Version: 0.1.0
4
+ Summary: Python client for 2s.io — pay-per-call AI agent APIs on Base via x402.
5
+ Project-URL: Homepage, https://2s.io
6
+ Project-URL: Source, https://github.com/2s-io/sdk
7
+ Project-URL: Issues, https://github.com/2s-io/sdk/issues
8
+ Author-email: Josh Alley <josh@alley.io>
9
+ License: MIT
10
+ Keywords: 2s.io,agentic,ai-agents,base,pay-per-call,stablecoin,usdc,x402
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: eth-account>=0.13
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: x402[httpx]>=2.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # 2sio (Python)
27
+
28
+ **Python client for [2s.io](https://2s.io) — pay-per-call AI agent APIs on Base via x402.**
29
+
30
+ ```bash
31
+ pip install "2sio[x402]"
32
+ ```
33
+
34
+ ## Quick start (x402, no signup)
35
+
36
+ ```python
37
+ import os
38
+ from eth_account import Account
39
+ from twosio import TwoS
40
+
41
+ account = Account.from_key(os.environ["EVM_PRIVATE_KEY"])
42
+ client = TwoS(signer=account)
43
+
44
+ r = client.patents.search(q="neural network", limit=5)
45
+ print(r.data["hits"][0]["title"])
46
+ print("paid:", r.cost_usd, "USDC, tx:", r.settlement["tx_hash"])
47
+ ```
48
+
49
+ Settles on Base mainnet in ~2 seconds. Prices start at $0.001/call.
50
+
51
+ ## Quick start (bearer)
52
+
53
+ ```python
54
+ client = TwoS(api_key=os.environ["TWOSIO_API_KEY"])
55
+ r = client.patents.search(q="neural network")
56
+ ```
57
+
58
+ ## What's included
59
+
60
+ 39 endpoints, namespaced by group:
61
+
62
+ ```python
63
+ client.patents.search(q="...")
64
+ client.patents.detail(applicationNumber="18566276")
65
+ client.crypto.address_validate(chain="eth", address="0xd8dA...")
66
+ client.ai.summarize(url="https://example.com")
67
+ client.law.sanctions_check(name="John Smith")
68
+ client.geocode.address(query="350 5th Ave, New York, NY")
69
+ client.weather.zip(zip="94103")
70
+ # ... and more
71
+ ```
72
+
73
+ Full catalog: <https://2s.io/api/directory>. OpenAPI: <https://2s.io/api/openapi>.
74
+
75
+ ## Safety
76
+
77
+ - The client refuses to sign payments above `max_price_usd` (default `$0.10`).
78
+ - Optional `on_payment_requested` hook for per-call approval.
79
+
80
+ ```python
81
+ client = TwoS(
82
+ signer=account,
83
+ max_price_usd=0.05,
84
+ on_payment_requested=lambda info: info["amount_usd"] < 0.02,
85
+ )
86
+ ```
87
+
88
+ ## Errors
89
+
90
+ - `TwoSError` — HTTP error from 2s.io.
91
+ - `PaymentRefusedError` — local refusal (price cap or hook).
92
+
93
+ ## License
94
+
95
+ MIT.
2sio-0.1.0/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # 2sio (Python)
2
+
3
+ **Python client for [2s.io](https://2s.io) — pay-per-call AI agent APIs on Base via x402.**
4
+
5
+ ```bash
6
+ pip install "2sio[x402]"
7
+ ```
8
+
9
+ ## Quick start (x402, no signup)
10
+
11
+ ```python
12
+ import os
13
+ from eth_account import Account
14
+ from twosio import TwoS
15
+
16
+ account = Account.from_key(os.environ["EVM_PRIVATE_KEY"])
17
+ client = TwoS(signer=account)
18
+
19
+ r = client.patents.search(q="neural network", limit=5)
20
+ print(r.data["hits"][0]["title"])
21
+ print("paid:", r.cost_usd, "USDC, tx:", r.settlement["tx_hash"])
22
+ ```
23
+
24
+ Settles on Base mainnet in ~2 seconds. Prices start at $0.001/call.
25
+
26
+ ## Quick start (bearer)
27
+
28
+ ```python
29
+ client = TwoS(api_key=os.environ["TWOSIO_API_KEY"])
30
+ r = client.patents.search(q="neural network")
31
+ ```
32
+
33
+ ## What's included
34
+
35
+ 39 endpoints, namespaced by group:
36
+
37
+ ```python
38
+ client.patents.search(q="...")
39
+ client.patents.detail(applicationNumber="18566276")
40
+ client.crypto.address_validate(chain="eth", address="0xd8dA...")
41
+ client.ai.summarize(url="https://example.com")
42
+ client.law.sanctions_check(name="John Smith")
43
+ client.geocode.address(query="350 5th Ave, New York, NY")
44
+ client.weather.zip(zip="94103")
45
+ # ... and more
46
+ ```
47
+
48
+ Full catalog: <https://2s.io/api/directory>. OpenAPI: <https://2s.io/api/openapi>.
49
+
50
+ ## Safety
51
+
52
+ - The client refuses to sign payments above `max_price_usd` (default `$0.10`).
53
+ - Optional `on_payment_requested` hook for per-call approval.
54
+
55
+ ```python
56
+ client = TwoS(
57
+ signer=account,
58
+ max_price_usd=0.05,
59
+ on_payment_requested=lambda info: info["amount_usd"] < 0.02,
60
+ )
61
+ ```
62
+
63
+ ## Errors
64
+
65
+ - `TwoSError` — HTTP error from 2s.io.
66
+ - `PaymentRefusedError` — local refusal (price cap or hook).
67
+
68
+ ## License
69
+
70
+ MIT.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "2sio"
3
+ version = "0.1.0"
4
+ description = "Python client for 2s.io — pay-per-call AI agent APIs on Base via x402."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Josh Alley", email = "josh@alley.io" }]
9
+ keywords = [
10
+ "x402",
11
+ "ai-agents",
12
+ "agentic",
13
+ "usdc",
14
+ "base",
15
+ "pay-per-call",
16
+ "2s.io",
17
+ "stablecoin",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Topic :: Internet :: WWW/HTTP",
29
+ ]
30
+ dependencies = [
31
+ "httpx>=0.27",
32
+ "eth-account>=0.13",
33
+ "x402[httpx]>=2.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://2s.io"
38
+ Source = "https://github.com/2s-io/sdk"
39
+ Issues = "https://github.com/2s-io/sdk/issues"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/twosio"]
@@ -0,0 +1,28 @@
1
+ """
2
+ 2sio — Python client for 2s.io's pay-per-call AI agent API.
3
+
4
+ Two auth modes:
5
+ - x402 (default): pass an eth_account ``LocalAccount``. The client auto-
6
+ handles 402 responses, signs an EIP-3009 USDC authorization via the
7
+ official ``x402`` SDK, retries, and returns the typed dict.
8
+ - Bearer: pass ``api_key=`` to debit a pre-funded account on 2s.io.
9
+
10
+ Example::
11
+
12
+ import os
13
+ from eth_account import Account
14
+ from twosio import TwoS
15
+
16
+ account = Account.from_key(os.environ["EVM_PRIVATE_KEY"])
17
+ client = TwoS(signer=account)
18
+
19
+ result = client.patents.search(q="neural network", limit=5)
20
+ print(result["data"]["hits"][0]["title"])
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from .client import TwoS, TwoSError, PaymentRefusedError
26
+
27
+ __all__ = ["TwoS", "TwoSError", "PaymentRefusedError"]
28
+ __version__ = "0.1.0"
@@ -0,0 +1,445 @@
1
+ """
2
+ TwoS client implementation. Synchronous + async variants share a request
3
+ core that handles 402-aware retries.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Awaitable, Callable, Optional, Union
10
+
11
+ import httpx
12
+
13
+ DEFAULT_BASE = "https://2s.io"
14
+ DEFAULT_MAX_PRICE_USD = 0.10
15
+
16
+
17
+ class TwoSError(Exception):
18
+ """HTTP error from 2s.io after payment (4xx/5xx)."""
19
+
20
+ def __init__(self, message: str, status: int, code: Optional[str], url: str):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.code = code
24
+ self.url = url
25
+
26
+
27
+ class PaymentRefusedError(Exception):
28
+ """Local refusal — price exceeded ``max_price_usd`` or hook denied."""
29
+
30
+ def __init__(self, message: str, url: str, advertised_usd: float):
31
+ super().__init__(message)
32
+ self.url = url
33
+ self.advertised_usd = advertised_usd
34
+
35
+
36
+ @dataclass
37
+ class CallResult:
38
+ """Normalized return value for every endpoint call."""
39
+
40
+ data: Any
41
+ """Parsed response body."""
42
+ endpoint: str
43
+ """Endpoint id, e.g. ``"patents.search"``."""
44
+ cost_usd: float = 0.0
45
+ """Final amount paid in USD."""
46
+ settlement: Optional[dict] = None
47
+ """x402 settlement info: tx_hash, network, success."""
48
+ balance_usd: Optional[float] = None
49
+ """Balance after debit, on bearer calls."""
50
+
51
+
52
+ class _Group:
53
+ """Marker base for namespaced endpoint groups (client.patents, client.ai, ...)."""
54
+
55
+ def __init__(self, client: "TwoS"):
56
+ self._c = client
57
+
58
+
59
+ class _Patents(_Group):
60
+ def search(self, **kwargs) -> CallResult:
61
+ return self._c.request("GET", "/api/patents/search", endpoint="patents.search", query=kwargs)
62
+
63
+ def detail(self, applicationNumber: str) -> CallResult:
64
+ return self._c.request(
65
+ "GET", "/api/patents/detail",
66
+ endpoint="patents.detail",
67
+ query={"applicationNumber": applicationNumber},
68
+ )
69
+
70
+ def documents(self, applicationNumber: str) -> CallResult:
71
+ return self._c.request(
72
+ "GET", "/api/patents/documents",
73
+ endpoint="patents.documents",
74
+ query={"applicationNumber": applicationNumber},
75
+ )
76
+
77
+
78
+ class _Crypto(_Group):
79
+ def address_validate(self, *, chain: str, address: str) -> CallResult:
80
+ return self._c.request(
81
+ "GET", "/api/crypto/address-validate",
82
+ endpoint="crypto.address-validate",
83
+ query={"chain": chain, "address": address},
84
+ )
85
+
86
+ def gas_oracle(self, *, chain: str = "base") -> CallResult:
87
+ return self._c.request(
88
+ "GET", "/api/crypto/gas-oracle",
89
+ endpoint="crypto.gas-oracle",
90
+ query={"chain": chain},
91
+ )
92
+
93
+
94
+ class _Ai(_Group):
95
+ def summarize(self, *, url: str, instruction: Optional[str] = None) -> CallResult:
96
+ body = {"url": url}
97
+ if instruction is not None:
98
+ body["instruction"] = instruction
99
+ return self._c.request("POST", "/api/ai/summarize", endpoint="ai.summarize", body=body)
100
+
101
+ def translate(self, *, text: str, target: str, source: Optional[str] = None) -> CallResult:
102
+ body: dict[str, Any] = {"text": text, "target": target}
103
+ if source is not None:
104
+ body["source"] = source
105
+ return self._c.request("POST", "/api/ai/translate", endpoint="ai.translate", body=body)
106
+
107
+ def extract(self, *, url: str, schema: dict, instruction: Optional[str] = None) -> CallResult:
108
+ body: dict[str, Any] = {"url": url, "schema": schema}
109
+ if instruction is not None:
110
+ body["instruction"] = instruction
111
+ return self._c.request("POST", "/api/ai/extract", endpoint="ai.extract", body=body)
112
+
113
+ def describe_image(self, *, url: Optional[str] = None, base64: Optional[str] = None) -> CallResult:
114
+ body: dict[str, Any] = {}
115
+ if url is not None:
116
+ body["url"] = url
117
+ if base64 is not None:
118
+ body["base64"] = base64
119
+ return self._c.request("POST", "/api/ai/describe-image", endpoint="ai.describe-image", body=body)
120
+
121
+ def screenshot(self, *, url: str, viewport_width: int = 1280, viewport_height: int = 800,
122
+ full_page: bool = False) -> CallResult:
123
+ return self._c.request(
124
+ "POST", "/api/ai/screenshot", endpoint="ai.screenshot",
125
+ body={
126
+ "url": url,
127
+ "viewportWidth": viewport_width,
128
+ "viewportHeight": viewport_height,
129
+ "fullPage": full_page,
130
+ },
131
+ )
132
+
133
+
134
+ class _Law(_Group):
135
+ def case_search(self, **kwargs) -> CallResult:
136
+ return self._c.request("GET", "/api/law/case-search", endpoint="law.case-search", query=kwargs)
137
+
138
+ def case_verify(self, *, citation: str) -> CallResult:
139
+ return self._c.request("GET", "/api/law/case-verify", endpoint="law.case-verify", query={"citation": citation})
140
+
141
+ def sanctions_check(self, *, name: str, min_score: float = 0.7, limit: int = 10) -> CallResult:
142
+ return self._c.request(
143
+ "GET", "/api/law/sanctions-check", endpoint="law.sanctions-check",
144
+ query={"name": name, "minScore": min_score, "limit": limit},
145
+ )
146
+
147
+ def federal_register(self, **kwargs) -> CallResult:
148
+ return self._c.request("GET", "/api/law/federal-register", endpoint="law.federal-register", query=kwargs)
149
+
150
+ def opinion(self, *, id: Union[str, int]) -> CallResult:
151
+ return self._c.request("GET", "/api/law/opinion", endpoint="law.opinion", query={"id": id})
152
+
153
+
154
+ class _Geocode(_Group):
155
+ def address(self, *, query: str, country_code: Optional[str] = None) -> CallResult:
156
+ q: dict[str, Any] = {"query": query}
157
+ if country_code is not None:
158
+ q["countryCode"] = country_code
159
+ return self._c.request("GET", "/api/geocode/address", endpoint="geocode.address", query=q)
160
+
161
+ def reverse(self, *, lat: float, lon: float) -> CallResult:
162
+ return self._c.request("GET", "/api/geocode/reverse", endpoint="geocode.reverse", query={"lat": lat, "lon": lon})
163
+
164
+
165
+ class _Airport(_Group):
166
+ def lookup(self, **kwargs) -> CallResult:
167
+ return self._c.request("GET", "/api/airport/lookup", endpoint="airport.lookup", query=kwargs)
168
+
169
+ def near(self, *, lat: float, lon: float, limit: int = 5) -> CallResult:
170
+ return self._c.request("GET", "/api/airport/near", endpoint="airport.near",
171
+ query={"lat": lat, "lon": lon, "limit": limit})
172
+
173
+
174
+ class _Weather(_Group):
175
+ def zip(self, *, zip: str) -> CallResult:
176
+ return self._c.request("GET", "/api/weather/zip", endpoint="weather.zip", query={"zip": zip})
177
+
178
+
179
+ class _Dns(_Group):
180
+ def lookup(self, *, name: str, type: str = "A") -> CallResult:
181
+ return self._c.request("GET", "/api/dns/lookup", endpoint="dns.lookup", query={"name": name, "type": type})
182
+
183
+
184
+ class _Domain(_Group):
185
+ def whois(self, *, domain: str) -> CallResult:
186
+ return self._c.request("GET", "/api/domain/whois", endpoint="domain.whois", query={"domain": domain})
187
+
188
+
189
+ class _Url(_Group):
190
+ def unfurl(self, *, url: str) -> CallResult:
191
+ return self._c.request("GET", "/api/url/unfurl", endpoint="url.unfurl", query={"url": url})
192
+
193
+ def clean(self, *, url: str) -> CallResult:
194
+ return self._c.request("GET", "/api/url/clean", endpoint="url.clean", query={"url": url})
195
+
196
+
197
+ class _Wikipedia(_Group):
198
+ def summary(self, *, title: str) -> CallResult:
199
+ return self._c.request("GET", "/api/wikipedia/summary", endpoint="wikipedia.summary", query={"title": title})
200
+
201
+
202
+ class _Papers(_Group):
203
+ def search(self, **kwargs) -> CallResult:
204
+ return self._c.request("GET", "/api/papers/search", endpoint="papers.search", query=kwargs)
205
+
206
+
207
+ class _Geo(_Group):
208
+ def ip(self, *, ip: str) -> CallResult:
209
+ return self._c.request("GET", "/api/geo/ip", endpoint="geo.ip", query={"ip": ip})
210
+
211
+
212
+ class _Ipinfo(_Group):
213
+ def bulk(self, *, ips: list[str]) -> CallResult:
214
+ return self._c.request("POST", "/api/ipinfo/bulk", endpoint="ipinfo.bulk", body={"ips": ips})
215
+
216
+
217
+ class _Hash(_Group):
218
+ def compute(self, **kwargs) -> CallResult:
219
+ return self._c.request("POST", "/api/hash/compute", endpoint="hash.compute", body=kwargs)
220
+
221
+
222
+ class _Quakes(_Group):
223
+ def recent(self, **kwargs) -> CallResult:
224
+ return self._c.request("GET", "/api/quakes/recent", endpoint="quakes.recent", query=kwargs)
225
+
226
+
227
+ class _Sunrise(_Group):
228
+ def compute(self, *, lat: float, lon: float, date: Optional[str] = None) -> CallResult:
229
+ q: dict[str, Any] = {"lat": lat, "lon": lon}
230
+ if date is not None:
231
+ q["date"] = date
232
+ return self._c.request("GET", "/api/sunrise/compute", endpoint="sunrise.compute", query=q)
233
+
234
+
235
+ class _Tides(_Group):
236
+ def now(self, *, lat: float, lon: float) -> CallResult:
237
+ return self._c.request("GET", "/api/tides/now", endpoint="tides.now", query={"lat": lat, "lon": lon})
238
+
239
+
240
+ class _Earth(_Group):
241
+ def now(self, *, lat: float, lon: float) -> CallResult:
242
+ return self._c.request("GET", "/api/earth/now", endpoint="earth.now", query={"lat": lat, "lon": lon})
243
+
244
+
245
+ class _Climate(_Group):
246
+ def station_near(self, *, lat: float, lon: float, limit: int = 5) -> CallResult:
247
+ return self._c.request("GET", "/api/climate/station-near", endpoint="climate.station-near",
248
+ query={"lat": lat, "lon": lon, "limit": limit})
249
+
250
+
251
+ class _Census(_Group):
252
+ def zipcode(self, *, zip: str) -> CallResult:
253
+ return self._c.request("GET", "/api/census/zipcode", endpoint="census.zipcode", query={"zip": zip})
254
+
255
+
256
+ class _Account(_Group):
257
+ def balance(self) -> CallResult:
258
+ return self._c.request("GET", "/api/account/balance", endpoint="account.balance")
259
+
260
+
261
+ class TwoS:
262
+ """
263
+ Main client for 2s.io. Construct once, reuse across calls.
264
+
265
+ Args:
266
+ signer: ``eth_account.LocalAccount`` for x402 payment signing.
267
+ api_key: Pre-funded 2s.io API key for bearer billing.
268
+ base_url: Override the default ``https://2s.io`` host.
269
+ max_price_usd: Local ceiling on per-call payment. Defaults to ``$0.10``.
270
+ on_payment_requested: Optional ``(info) -> bool`` hook fired before signing.
271
+ """
272
+
273
+ def __init__(
274
+ self,
275
+ *,
276
+ signer: Any = None,
277
+ api_key: Optional[str] = None,
278
+ base_url: str = DEFAULT_BASE,
279
+ max_price_usd: float = DEFAULT_MAX_PRICE_USD,
280
+ on_payment_requested: Optional[Callable[[dict], bool]] = None,
281
+ timeout: float = 30.0,
282
+ ):
283
+ if signer is None and not api_key:
284
+ raise ValueError("TwoS requires either signer=... (x402) or api_key=... (bearer)")
285
+ self.signer = signer
286
+ self.api_key = api_key
287
+ self.base_url = base_url.rstrip("/")
288
+ self.max_price_usd = max_price_usd
289
+ self.on_payment_requested = on_payment_requested
290
+ self._http: Optional[httpx.Client] = None
291
+ self._timeout = timeout
292
+ self._x402_client = None # lazy
293
+
294
+ self.patents = _Patents(self)
295
+ self.crypto = _Crypto(self)
296
+ self.ai = _Ai(self)
297
+ self.law = _Law(self)
298
+ self.geocode = _Geocode(self)
299
+ self.airport = _Airport(self)
300
+ self.weather = _Weather(self)
301
+ self.dns = _Dns(self)
302
+ self.domain = _Domain(self)
303
+ self.url = _Url(self)
304
+ self.wikipedia = _Wikipedia(self)
305
+ self.papers = _Papers(self)
306
+ self.geo = _Geo(self)
307
+ self.ipinfo = _Ipinfo(self)
308
+ self.hash = _Hash(self)
309
+ self.quakes = _Quakes(self)
310
+ self.sunrise = _Sunrise(self)
311
+ self.tides = _Tides(self)
312
+ self.earth = _Earth(self)
313
+ self.climate = _Climate(self)
314
+ self.census = _Census(self)
315
+ self.account = _Account(self)
316
+
317
+ def _client(self) -> httpx.Client:
318
+ if self._http is None:
319
+ self._http = httpx.Client(timeout=self._timeout)
320
+ return self._http
321
+
322
+ def _get_x402_client(self):
323
+ if self._x402_client is not None:
324
+ return self._x402_client
325
+ if self.signer is None:
326
+ raise RuntimeError("x402 call attempted but no signer was configured.")
327
+ # Lazy import — only paying users need the x402 dep loaded.
328
+ from x402 import x402Client # type: ignore
329
+ from x402.mechanisms.evm import EthAccountSigner # type: ignore
330
+ from x402.mechanisms.evm.exact.register import register_exact_evm_client # type: ignore
331
+
332
+ c = x402Client()
333
+ register_exact_evm_client(c, EthAccountSigner(self.signer))
334
+ self._x402_client = c
335
+ return c
336
+
337
+ def request(
338
+ self,
339
+ method: str,
340
+ path: str,
341
+ *,
342
+ endpoint: str,
343
+ query: Optional[dict] = None,
344
+ body: Optional[dict] = None,
345
+ ) -> CallResult:
346
+ """Low-level call. Endpoint methods use this internally."""
347
+ url = self.base_url + path
348
+ params = {k: v for k, v in (query or {}).items() if v is not None}
349
+ headers: dict[str, str] = {}
350
+ if self.api_key:
351
+ headers["Authorization"] = f"Bearer {self.api_key}"
352
+
353
+ http = self._client()
354
+ if body is not None:
355
+ res = http.request(method, url, params=params, json=body, headers=headers)
356
+ else:
357
+ res = http.request(method, url, params=params, headers=headers)
358
+
359
+ if res.status_code != 402:
360
+ return self._parse(res, endpoint, url)
361
+
362
+ # 402 — sign and retry via x402 SDK.
363
+ from x402.http import x402HTTPClient # type: ignore
364
+
365
+ body_json = res.json()
366
+ # The x402 Python SDK exposes a helper to read PaymentRequired from a
367
+ # combination of headers + body. We construct the lightweight shim here.
368
+ def get_header(name: str) -> Optional[str]:
369
+ return res.headers.get(name)
370
+
371
+ client = self._get_x402_client()
372
+ http_helper = x402HTTPClient(client)
373
+ required = http_helper.get_payment_required_response(get_header, body_json)
374
+ if not required.accepts:
375
+ raise TwoSError("402 missing accepts[]", 402, "BAD_402", url)
376
+ accepts = required.accepts[0]
377
+ amount_usd = int(accepts.amount) / 1_000_000
378
+ if amount_usd > self.max_price_usd:
379
+ raise PaymentRefusedError(
380
+ f"price ${amount_usd} > max_price_usd ${self.max_price_usd}",
381
+ url, amount_usd,
382
+ )
383
+ if self.on_payment_requested is not None:
384
+ info = {"url": url, "amount_usd": amount_usd, "network": accepts.network, "pay_to": accepts.pay_to}
385
+ if not self.on_payment_requested(info):
386
+ raise PaymentRefusedError("on_payment_requested denied", url, amount_usd)
387
+
388
+ payload = client.create_payment_payload(required)
389
+ sig_headers = http_helper.encode_payment_signature_header(payload)
390
+ merged = {**headers, **sig_headers}
391
+
392
+ if body is not None:
393
+ res2 = http.request(method, url, params=params, json=body, headers=merged)
394
+ else:
395
+ res2 = http.request(method, url, params=params, headers=merged)
396
+ return self._parse(res2, endpoint, url)
397
+
398
+ def _parse(self, res: httpx.Response, endpoint: str, url: str) -> CallResult:
399
+ ct = res.headers.get("content-type", "")
400
+ tx_hash = res.headers.get("x-payment-tx")
401
+ settlement = None
402
+ resp_hdr = res.headers.get("payment-response") or res.headers.get("x-payment-response")
403
+ if resp_hdr:
404
+ import base64
405
+ import json
406
+ try:
407
+ decoded = json.loads(base64.b64decode(resp_hdr).decode("utf-8"))
408
+ settlement = {
409
+ "tx_hash": decoded.get("transaction") or tx_hash,
410
+ "network": decoded.get("network"),
411
+ "success": bool(decoded.get("success")),
412
+ }
413
+ except Exception:
414
+ if tx_hash:
415
+ settlement = {"tx_hash": tx_hash, "network": None, "success": True}
416
+
417
+ if "application/json" in ct:
418
+ j = res.json()
419
+ if not res.is_success:
420
+ err = j.get("error") or {}
421
+ raise TwoSError(err.get("message") or f"HTTP {res.status_code}",
422
+ res.status_code, err.get("code"), url)
423
+ return CallResult(
424
+ data=j.get("data", j),
425
+ endpoint=endpoint,
426
+ cost_usd=(j.get("meta", {}).get("cost", {}) or {}).get("usd", 0.0),
427
+ settlement=settlement,
428
+ balance_usd=(j.get("meta", {}).get("balance", {}) or {}).get("usd"),
429
+ )
430
+
431
+ # Binary
432
+ if not res.is_success:
433
+ raise TwoSError(res.text[:200], res.status_code, None, url)
434
+ return CallResult(data=res.content, endpoint=endpoint, settlement=settlement)
435
+
436
+ def close(self) -> None:
437
+ if self._http is not None:
438
+ self._http.close()
439
+ self._http = None
440
+
441
+ def __enter__(self) -> "TwoS":
442
+ return self
443
+
444
+ def __exit__(self, *args) -> None:
445
+ self.close()