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 +12 -0
- 2sio-0.1.0/PKG-INFO +95 -0
- 2sio-0.1.0/README.md +70 -0
- 2sio-0.1.0/pyproject.toml +46 -0
- 2sio-0.1.0/src/twosio/__init__.py +28 -0
- 2sio-0.1.0/src/twosio/client.py +445 -0
2sio-0.1.0/.gitignore
ADDED
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()
|