x402-pay 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.
- x402_pay-0.1.0/LICENSE +21 -0
- x402_pay-0.1.0/PKG-INFO +122 -0
- x402_pay-0.1.0/README.md +95 -0
- x402_pay-0.1.0/pyproject.toml +40 -0
- x402_pay-0.1.0/setup.cfg +4 -0
- x402_pay-0.1.0/src/x402_pay/__init__.py +30 -0
- x402_pay-0.1.0/src/x402_pay/client.py +93 -0
- x402_pay-0.1.0/src/x402_pay/config.py +66 -0
- x402_pay-0.1.0/src/x402_pay/direct.py +82 -0
- x402_pay-0.1.0/src/x402_pay/exceptions.py +41 -0
- x402_pay-0.1.0/src/x402_pay/keys.py +79 -0
- x402_pay-0.1.0/src/x402_pay/transport.py +90 -0
- x402_pay-0.1.0/src/x402_pay.egg-info/PKG-INFO +122 -0
- x402_pay-0.1.0/src/x402_pay.egg-info/SOURCES.txt +18 -0
- x402_pay-0.1.0/src/x402_pay.egg-info/dependency_links.txt +1 -0
- x402_pay-0.1.0/src/x402_pay.egg-info/requires.txt +5 -0
- x402_pay-0.1.0/src/x402_pay.egg-info/top_level.txt +1 -0
- x402_pay-0.1.0/tests/test_client.py +115 -0
- x402_pay-0.1.0/tests/test_keys.py +100 -0
- x402_pay-0.1.0/tests/test_transport.py +159 -0
x402_pay-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hugen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
x402_pay-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: x402-pay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pay for x402 APIs with 3 lines of Python — no wallet needed
|
|
5
|
+
Author-email: hugen <dev@hugen.tokyo>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hugen-tokyo/x402-pay
|
|
8
|
+
Project-URL: Documentation, https://discovery.hugen.tokyo/llms.txt
|
|
9
|
+
Project-URL: Issues, https://github.com/hugen-tokyo/x402-pay/issues
|
|
10
|
+
Keywords: x402,micropayments,api,pay-per-call,usdc
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Provides-Extra: wallet
|
|
24
|
+
Requires-Dist: x402[evm,httpx]>=2.1.0; extra == "wallet"
|
|
25
|
+
Requires-Dist: eth-account>=0.13; extra == "wallet"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# x402-pay
|
|
29
|
+
|
|
30
|
+
Call any x402 API with one API key. No per-API wallet integration needed.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from x402_pay import PayClient
|
|
34
|
+
|
|
35
|
+
async with PayClient(api_key="gw_your_topped_up_key") as client:
|
|
36
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
37
|
+
print(resp.json())
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Routes requests through a broker that handles on-chain payment on your behalf. $0.01/call.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install x402-pay
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Modes
|
|
49
|
+
|
|
50
|
+
### API Key Mode (default, no wallet)
|
|
51
|
+
|
|
52
|
+
Get a key and top it up first:
|
|
53
|
+
```bash
|
|
54
|
+
# Create key (free, gets $0.05 search credit)
|
|
55
|
+
curl -X POST https://discovery.hugen.tokyo/keys/create
|
|
56
|
+
# Top up to unlock broker ($1.00 x402 payment)
|
|
57
|
+
curl -X POST "https://discovery.hugen.tokyo/keys/topup?key=gw_YOUR_KEY"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Then use it:
|
|
61
|
+
```python
|
|
62
|
+
from x402_pay import PayClient
|
|
63
|
+
|
|
64
|
+
async with PayClient(api_key="gw_YOUR_KEY") as client:
|
|
65
|
+
# Any x402 API — broker pays upstream, deducts $0.01 from your key
|
|
66
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
67
|
+
print(resp.json())
|
|
68
|
+
|
|
69
|
+
# Check remaining balance
|
|
70
|
+
print(f"${await client.balance():.2f}")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Wallet Mode (power users)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install x402-pay[wallet]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from x402_pay import DirectClient
|
|
81
|
+
|
|
82
|
+
async with DirectClient(private_key="0x...") as client:
|
|
83
|
+
# Pay upstream directly from your wallet — no broker fee
|
|
84
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
85
|
+
print(resp.json())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
API keys are stored in `~/.x402-pay/config.json`. Override with environment variables:
|
|
91
|
+
|
|
92
|
+
| Variable | Default | Description |
|
|
93
|
+
|----------|---------|-------------|
|
|
94
|
+
| `X402_API_KEY` | auto-created | API key for broker mode |
|
|
95
|
+
| `X402_BROKER_URL` | `https://discovery.hugen.tokyo` | Broker endpoint |
|
|
96
|
+
| `X402_PAY_CONFIG_DIR` | `~/.x402-pay` | Config directory |
|
|
97
|
+
|
|
98
|
+
## API
|
|
99
|
+
|
|
100
|
+
### PayClient
|
|
101
|
+
|
|
102
|
+
- `PayClient(api_key="", broker_url="", timeout=60.0)` — create client
|
|
103
|
+
- `await client.get(url)` — GET request through broker
|
|
104
|
+
- `await client.post(url, json={...})` — POST request through broker
|
|
105
|
+
- `await client.balance()` — check key balance in USD
|
|
106
|
+
- `client.api_key` — current API key
|
|
107
|
+
|
|
108
|
+
### DirectClient
|
|
109
|
+
|
|
110
|
+
- `DirectClient(private_key="0x...")` — create wallet client
|
|
111
|
+
- `await client.get(url)` / `await client.post(url)` — direct x402 payment
|
|
112
|
+
|
|
113
|
+
### Exceptions
|
|
114
|
+
|
|
115
|
+
- `PayError` — base exception
|
|
116
|
+
- `InsufficientBalance` — key balance too low (`.balance`, `.needed`, `.topup_url`)
|
|
117
|
+
- `NotInCatalog` — URL not in x402 catalog (`.url`)
|
|
118
|
+
- `BrokerError` — upstream call failed (`.refunded`)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
x402_pay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# x402-pay
|
|
2
|
+
|
|
3
|
+
Call any x402 API with one API key. No per-API wallet integration needed.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from x402_pay import PayClient
|
|
7
|
+
|
|
8
|
+
async with PayClient(api_key="gw_your_topped_up_key") as client:
|
|
9
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
10
|
+
print(resp.json())
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Routes requests through a broker that handles on-chain payment on your behalf. $0.01/call.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install x402-pay
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Modes
|
|
22
|
+
|
|
23
|
+
### API Key Mode (default, no wallet)
|
|
24
|
+
|
|
25
|
+
Get a key and top it up first:
|
|
26
|
+
```bash
|
|
27
|
+
# Create key (free, gets $0.05 search credit)
|
|
28
|
+
curl -X POST https://discovery.hugen.tokyo/keys/create
|
|
29
|
+
# Top up to unlock broker ($1.00 x402 payment)
|
|
30
|
+
curl -X POST "https://discovery.hugen.tokyo/keys/topup?key=gw_YOUR_KEY"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then use it:
|
|
34
|
+
```python
|
|
35
|
+
from x402_pay import PayClient
|
|
36
|
+
|
|
37
|
+
async with PayClient(api_key="gw_YOUR_KEY") as client:
|
|
38
|
+
# Any x402 API — broker pays upstream, deducts $0.01 from your key
|
|
39
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
40
|
+
print(resp.json())
|
|
41
|
+
|
|
42
|
+
# Check remaining balance
|
|
43
|
+
print(f"${await client.balance():.2f}")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Wallet Mode (power users)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install x402-pay[wallet]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from x402_pay import DirectClient
|
|
54
|
+
|
|
55
|
+
async with DirectClient(private_key="0x...") as client:
|
|
56
|
+
# Pay upstream directly from your wallet — no broker fee
|
|
57
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
58
|
+
print(resp.json())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
API keys are stored in `~/.x402-pay/config.json`. Override with environment variables:
|
|
64
|
+
|
|
65
|
+
| Variable | Default | Description |
|
|
66
|
+
|----------|---------|-------------|
|
|
67
|
+
| `X402_API_KEY` | auto-created | API key for broker mode |
|
|
68
|
+
| `X402_BROKER_URL` | `https://discovery.hugen.tokyo` | Broker endpoint |
|
|
69
|
+
| `X402_PAY_CONFIG_DIR` | `~/.x402-pay` | Config directory |
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
### PayClient
|
|
74
|
+
|
|
75
|
+
- `PayClient(api_key="", broker_url="", timeout=60.0)` — create client
|
|
76
|
+
- `await client.get(url)` — GET request through broker
|
|
77
|
+
- `await client.post(url, json={...})` — POST request through broker
|
|
78
|
+
- `await client.balance()` — check key balance in USD
|
|
79
|
+
- `client.api_key` — current API key
|
|
80
|
+
|
|
81
|
+
### DirectClient
|
|
82
|
+
|
|
83
|
+
- `DirectClient(private_key="0x...")` — create wallet client
|
|
84
|
+
- `await client.get(url)` / `await client.post(url)` — direct x402 payment
|
|
85
|
+
|
|
86
|
+
### Exceptions
|
|
87
|
+
|
|
88
|
+
- `PayError` — base exception
|
|
89
|
+
- `InsufficientBalance` — key balance too low (`.balance`, `.needed`, `.topup_url`)
|
|
90
|
+
- `NotInCatalog` — URL not in x402 catalog (`.url`)
|
|
91
|
+
- `BrokerError` — upstream call failed (`.refunded`)
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "x402-pay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pay for x402 APIs with 3 lines of Python — no wallet needed"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{name = "hugen", email = "dev@hugen.tokyo"}]
|
|
13
|
+
keywords = ["x402", "micropayments", "api", "pay-per-call", "usdc"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
wallet = [
|
|
30
|
+
"x402[httpx,evm]>=2.1.0",
|
|
31
|
+
"eth-account>=0.13",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/hugen-tokyo/x402-pay"
|
|
36
|
+
Documentation = "https://discovery.hugen.tokyo/llms.txt"
|
|
37
|
+
Issues = "https://github.com/hugen-tokyo/x402-pay/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
x402_pay-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""x402-pay — Pay for x402 APIs with 3 lines of Python.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from x402_pay import PayClient
|
|
6
|
+
|
|
7
|
+
async with PayClient() as client:
|
|
8
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
9
|
+
print(resp.json())
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .client import PayClient
|
|
13
|
+
from .direct import DirectClient
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
BrokerError,
|
|
16
|
+
InsufficientBalance,
|
|
17
|
+
NotInCatalog,
|
|
18
|
+
PayError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"PayClient",
|
|
23
|
+
"DirectClient",
|
|
24
|
+
"PayError",
|
|
25
|
+
"InsufficientBalance",
|
|
26
|
+
"NotInCatalog",
|
|
27
|
+
"BrokerError",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""PayClient — the main entry point for x402-pay.
|
|
2
|
+
|
|
3
|
+
API key mode: routes all requests through the broker REST API.
|
|
4
|
+
No wallet, no on-chain transactions, no crypto dependencies needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import config
|
|
12
|
+
from .keys import ensure_key, get_balance
|
|
13
|
+
from .transport import BrokerTransport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PayClient:
|
|
17
|
+
"""Call any x402 API with just an API key — no wallet needed.
|
|
18
|
+
|
|
19
|
+
Auto-provisions a key with $0.05 trial balance on first use.
|
|
20
|
+
|
|
21
|
+
Usage::
|
|
22
|
+
|
|
23
|
+
async with PayClient() as client:
|
|
24
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
25
|
+
print(resp.json())
|
|
26
|
+
print(f"Balance: ${await client.balance():.2f}")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
api_key: str = "",
|
|
32
|
+
broker_url: str = "",
|
|
33
|
+
timeout: float = 60.0,
|
|
34
|
+
):
|
|
35
|
+
self._api_key = api_key
|
|
36
|
+
self._broker_url = broker_url or config.get_broker_url()
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
self._transport: BrokerTransport | None = None
|
|
39
|
+
self._client: httpx.AsyncClient | None = None
|
|
40
|
+
|
|
41
|
+
async def _ensure_init(self):
|
|
42
|
+
"""Lazy init: provision key + create transport on first use."""
|
|
43
|
+
if self._client is not None:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if not self._api_key:
|
|
47
|
+
self._api_key = await ensure_key()
|
|
48
|
+
|
|
49
|
+
self._transport = BrokerTransport(
|
|
50
|
+
api_key=self._api_key,
|
|
51
|
+
broker_url=self._broker_url,
|
|
52
|
+
timeout=self._timeout,
|
|
53
|
+
)
|
|
54
|
+
self._client = httpx.AsyncClient(transport=self._transport)
|
|
55
|
+
|
|
56
|
+
async def get(self, url: str, **kwargs) -> httpx.Response:
|
|
57
|
+
"""Send a GET request through the broker."""
|
|
58
|
+
await self._ensure_init()
|
|
59
|
+
assert self._client is not None
|
|
60
|
+
return await self._client.get(url, **kwargs)
|
|
61
|
+
|
|
62
|
+
async def post(self, url: str, **kwargs) -> httpx.Response:
|
|
63
|
+
"""Send a POST request through the broker."""
|
|
64
|
+
await self._ensure_init()
|
|
65
|
+
assert self._client is not None
|
|
66
|
+
return await self._client.post(url, **kwargs)
|
|
67
|
+
|
|
68
|
+
async def balance(self) -> float:
|
|
69
|
+
"""Check current API key balance in USD."""
|
|
70
|
+
if not self._api_key:
|
|
71
|
+
self._api_key = await ensure_key()
|
|
72
|
+
info = await get_balance(self._api_key)
|
|
73
|
+
return info.get("balance_usd", 0.0)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def api_key(self) -> str:
|
|
77
|
+
"""Return the current API key (may be empty before first call)."""
|
|
78
|
+
return self._api_key
|
|
79
|
+
|
|
80
|
+
async def __aenter__(self):
|
|
81
|
+
await self._ensure_init()
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
async def __aexit__(self, *exc):
|
|
85
|
+
await self.close()
|
|
86
|
+
|
|
87
|
+
async def close(self):
|
|
88
|
+
if self._client:
|
|
89
|
+
await self._client.aclose()
|
|
90
|
+
self._client = None
|
|
91
|
+
if self._transport:
|
|
92
|
+
await self._transport.aclose()
|
|
93
|
+
self._transport = None
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""x402-pay local configuration management.
|
|
2
|
+
|
|
3
|
+
Stores API key and settings in ~/.x402-pay/config.json.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_DEFAULTS = {
|
|
11
|
+
"broker_url": "https://discovery.hugen.tokyo",
|
|
12
|
+
"api_key": "",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _config_dir() -> Path:
|
|
17
|
+
return Path(os.getenv("X402_PAY_CONFIG_DIR", "~/.x402-pay")).expanduser()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _config_file() -> Path:
|
|
21
|
+
return _config_dir() / "config.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ensure_dir():
|
|
25
|
+
_config_dir().mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load() -> dict:
|
|
29
|
+
"""Load config, returning defaults if file doesn't exist."""
|
|
30
|
+
cf = _config_file()
|
|
31
|
+
if not cf.exists():
|
|
32
|
+
return dict(_DEFAULTS)
|
|
33
|
+
try:
|
|
34
|
+
with open(cf) as f:
|
|
35
|
+
data = json.load(f)
|
|
36
|
+
merged = dict(_DEFAULTS)
|
|
37
|
+
merged.update(data)
|
|
38
|
+
return merged
|
|
39
|
+
except (json.JSONDecodeError, OSError):
|
|
40
|
+
return dict(_DEFAULTS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save(config: dict):
|
|
44
|
+
"""Persist config to disk."""
|
|
45
|
+
_ensure_dir()
|
|
46
|
+
cf = _config_file()
|
|
47
|
+
with open(cf, "w") as f:
|
|
48
|
+
json.dump(config, f, indent=2)
|
|
49
|
+
os.chmod(cf, 0o600)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_api_key() -> str:
|
|
53
|
+
"""Return cached API key, or empty string if none."""
|
|
54
|
+
return os.getenv("X402_API_KEY", "") or load().get("api_key", "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_api_key(key: str):
|
|
58
|
+
"""Save API key to local config."""
|
|
59
|
+
cfg = load()
|
|
60
|
+
cfg["api_key"] = key
|
|
61
|
+
save(cfg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_broker_url() -> str:
|
|
65
|
+
"""Return broker base URL."""
|
|
66
|
+
return os.getenv("X402_BROKER_URL", "") or load().get("broker_url", _DEFAULTS["broker_url"])
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""DirectClient — wallet-based direct x402 payment.
|
|
2
|
+
|
|
3
|
+
Power-user mode: pay upstream APIs directly from your own wallet.
|
|
4
|
+
No broker, no API key — just on-chain micropayments.
|
|
5
|
+
|
|
6
|
+
Requires: pip install x402-pay[wallet]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DirectClient:
|
|
18
|
+
"""Call any x402 API directly with your own wallet.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
async with DirectClient(private_key="0x...") as client:
|
|
23
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
24
|
+
print(resp.json())
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
private_key: str,
|
|
30
|
+
timeout: float = 60.0,
|
|
31
|
+
):
|
|
32
|
+
self._private_key = private_key
|
|
33
|
+
self._timeout = timeout
|
|
34
|
+
self._http: httpx.AsyncClient | None = None
|
|
35
|
+
|
|
36
|
+
async def _ensure_init(self):
|
|
37
|
+
if self._http is not None:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import httpx as _httpx
|
|
42
|
+
from eth_account import Account
|
|
43
|
+
from x402 import x402Client
|
|
44
|
+
from x402.http.clients import x402HttpxClient
|
|
45
|
+
from x402.mechanisms.evm import EthAccountSigner
|
|
46
|
+
from x402.mechanisms.evm.exact.register import register_exact_evm_client
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"DirectClient requires wallet dependencies. "
|
|
50
|
+
"Install with: pip install x402-pay[wallet]"
|
|
51
|
+
) from exc
|
|
52
|
+
|
|
53
|
+
account = Account.from_key(self._private_key)
|
|
54
|
+
client = x402Client()
|
|
55
|
+
register_exact_evm_client(client, EthAccountSigner(account))
|
|
56
|
+
|
|
57
|
+
self._http = x402HttpxClient(client, timeout=_httpx.Timeout(self._timeout))
|
|
58
|
+
await self._http.__aenter__()
|
|
59
|
+
|
|
60
|
+
async def get(self, url: str, **kwargs):
|
|
61
|
+
"""Send a GET request with x402 auto-payment."""
|
|
62
|
+
await self._ensure_init()
|
|
63
|
+
assert self._http is not None
|
|
64
|
+
return await self._http.get(url, **kwargs)
|
|
65
|
+
|
|
66
|
+
async def post(self, url: str, **kwargs):
|
|
67
|
+
"""Send a POST request with x402 auto-payment."""
|
|
68
|
+
await self._ensure_init()
|
|
69
|
+
assert self._http is not None
|
|
70
|
+
return await self._http.post(url, **kwargs)
|
|
71
|
+
|
|
72
|
+
async def __aenter__(self):
|
|
73
|
+
await self._ensure_init()
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
async def __aexit__(self, *exc):
|
|
77
|
+
await self.close()
|
|
78
|
+
|
|
79
|
+
async def close(self):
|
|
80
|
+
if self._http:
|
|
81
|
+
await self._http.__aexit__(None, None, None)
|
|
82
|
+
self._http = None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""x402-pay exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PayError(Exception):
|
|
5
|
+
"""Base exception for x402-pay."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InsufficientBalance(PayError):
|
|
9
|
+
"""API key balance too low for the requested call."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, balance: float, needed: float, topup_url: str = ""):
|
|
12
|
+
self.balance = balance
|
|
13
|
+
self.needed = needed
|
|
14
|
+
self.topup_url = topup_url
|
|
15
|
+
super().__init__(
|
|
16
|
+
f"Insufficient balance: ${balance:.4f} < ${needed:.4f}"
|
|
17
|
+
+ (f" — top up at {topup_url}" if topup_url else "")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotInCatalog(PayError):
|
|
22
|
+
"""URL not found in the x402 API catalog."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, url: str):
|
|
25
|
+
self.url = url
|
|
26
|
+
super().__init__(f"URL not in catalog: {url}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BrokerError(PayError):
|
|
30
|
+
"""Upstream API call failed via broker."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, refunded: bool = False):
|
|
33
|
+
self.refunded = refunded
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KeyError_(PayError):
|
|
38
|
+
"""API key is invalid or inactive."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str = "Invalid or inactive API key"):
|
|
41
|
+
super().__init__(message)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""API key management — create, check balance, top up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import config
|
|
8
|
+
from .exceptions import KeyError_, PayError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def create_key(
|
|
12
|
+
label: str = "x402-pay-auto",
|
|
13
|
+
*,
|
|
14
|
+
client: httpx.AsyncClient | None = None,
|
|
15
|
+
) -> dict:
|
|
16
|
+
"""Create a new API key with trial balance.
|
|
17
|
+
|
|
18
|
+
Returns dict with 'key', 'balance_usd', etc.
|
|
19
|
+
"""
|
|
20
|
+
base = config.get_broker_url()
|
|
21
|
+
url = f"{base}/keys/create"
|
|
22
|
+
params = {"label": label} if label else {}
|
|
23
|
+
|
|
24
|
+
close = False
|
|
25
|
+
if client is None:
|
|
26
|
+
client = httpx.AsyncClient(timeout=30)
|
|
27
|
+
close = True
|
|
28
|
+
try:
|
|
29
|
+
r = await client.post(url, params=params)
|
|
30
|
+
r.raise_for_status()
|
|
31
|
+
return r.json()
|
|
32
|
+
except httpx.HTTPStatusError as exc:
|
|
33
|
+
raise PayError(f"Key creation failed: {exc.response.status_code} {exc.response.text[:200]}") from exc
|
|
34
|
+
finally:
|
|
35
|
+
if close:
|
|
36
|
+
await client.aclose()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_balance(
|
|
40
|
+
key: str = "",
|
|
41
|
+
*,
|
|
42
|
+
client: httpx.AsyncClient | None = None,
|
|
43
|
+
) -> dict:
|
|
44
|
+
"""Check API key balance and usage stats."""
|
|
45
|
+
key = key or config.get_api_key()
|
|
46
|
+
if not key:
|
|
47
|
+
raise KeyError_("No API key configured. Call create_key() or set X402_API_KEY env var.")
|
|
48
|
+
|
|
49
|
+
base = config.get_broker_url()
|
|
50
|
+
url = f"{base}/keys/balance"
|
|
51
|
+
|
|
52
|
+
close = False
|
|
53
|
+
if client is None:
|
|
54
|
+
client = httpx.AsyncClient(timeout=30)
|
|
55
|
+
close = True
|
|
56
|
+
try:
|
|
57
|
+
r = await client.get(url, params={"key": key})
|
|
58
|
+
if r.status_code == 404:
|
|
59
|
+
raise KeyError_(f"Key not found: {key[:12]}...")
|
|
60
|
+
r.raise_for_status()
|
|
61
|
+
return r.json()
|
|
62
|
+
finally:
|
|
63
|
+
if close:
|
|
64
|
+
await client.aclose()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def ensure_key(*, client: httpx.AsyncClient | None = None) -> str:
|
|
68
|
+
"""Return existing API key, or auto-create one and save it.
|
|
69
|
+
|
|
70
|
+
This is the key provisioning logic used by PayClient on first use.
|
|
71
|
+
"""
|
|
72
|
+
existing = config.get_api_key()
|
|
73
|
+
if existing:
|
|
74
|
+
return existing
|
|
75
|
+
|
|
76
|
+
result = await create_key(client=client)
|
|
77
|
+
key = result["key"]
|
|
78
|
+
config.set_api_key(key)
|
|
79
|
+
return key
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""BrokerTransport — httpx AsyncBaseTransport that routes through the x402 broker.
|
|
2
|
+
|
|
3
|
+
All requests go through POST /broker/call with API key authentication.
|
|
4
|
+
The broker handles upstream x402 payment on the caller's behalf.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as _json
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .exceptions import BrokerError, InsufficientBalance, KeyError_, NotInCatalog
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BrokerTransport(httpx.AsyncBaseTransport):
|
|
17
|
+
"""httpx transport that proxies requests through the x402 broker REST API.
|
|
18
|
+
|
|
19
|
+
Usage::
|
|
20
|
+
|
|
21
|
+
transport = BrokerTransport(api_key="gw_xxx", broker_url="https://discovery.hugen.tokyo")
|
|
22
|
+
async with httpx.AsyncClient(transport=transport) as client:
|
|
23
|
+
r = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: str,
|
|
29
|
+
broker_url: str = "https://discovery.hugen.tokyo",
|
|
30
|
+
timeout: float = 60.0,
|
|
31
|
+
):
|
|
32
|
+
self._api_key = api_key
|
|
33
|
+
self._broker_url = broker_url.rstrip("/")
|
|
34
|
+
self._broker_endpoint = f"{self._broker_url}/broker/call"
|
|
35
|
+
self._http = httpx.AsyncClient(timeout=timeout)
|
|
36
|
+
|
|
37
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
38
|
+
"""Route request through broker, return upstream response."""
|
|
39
|
+
url = str(request.url)
|
|
40
|
+
method = request.method.upper()
|
|
41
|
+
|
|
42
|
+
# Build broker request body
|
|
43
|
+
body_payload: dict = {"url": url, "method": method}
|
|
44
|
+
if method == "POST" and request.content:
|
|
45
|
+
try:
|
|
46
|
+
body_payload["body"] = _json.loads(request.content)
|
|
47
|
+
except (ValueError, TypeError):
|
|
48
|
+
body_payload["body"] = None
|
|
49
|
+
|
|
50
|
+
broker_resp = await self._http.post(
|
|
51
|
+
self._broker_endpoint,
|
|
52
|
+
json=body_payload,
|
|
53
|
+
headers={"X-API-Key": self._api_key},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Map broker error responses to exceptions
|
|
57
|
+
if broker_resp.status_code == 401:
|
|
58
|
+
raise KeyError_("Invalid API key.")
|
|
59
|
+
if broker_resp.status_code == 402:
|
|
60
|
+
data = broker_resp.json()
|
|
61
|
+
raise InsufficientBalance(
|
|
62
|
+
balance=data.get("balance_usd", 0),
|
|
63
|
+
needed=data.get("needed", 0.01),
|
|
64
|
+
topup_url=data.get("topup", ""),
|
|
65
|
+
)
|
|
66
|
+
if broker_resp.status_code == 404:
|
|
67
|
+
raise NotInCatalog(url)
|
|
68
|
+
if broker_resp.status_code == 429:
|
|
69
|
+
raise BrokerError("Rate limited. Try again in a minute.")
|
|
70
|
+
if broker_resp.status_code == 502:
|
|
71
|
+
data = broker_resp.json()
|
|
72
|
+
raise BrokerError(
|
|
73
|
+
data.get("error", "Upstream error"),
|
|
74
|
+
refunded=data.get("refunded", False),
|
|
75
|
+
)
|
|
76
|
+
if broker_resp.status_code >= 400:
|
|
77
|
+
data = broker_resp.json() if broker_resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
78
|
+
raise BrokerError(data.get("error", f"Broker returned {broker_resp.status_code}"))
|
|
79
|
+
|
|
80
|
+
# Success — broker now returns upstream data directly in body,
|
|
81
|
+
# with cost/balance in headers. Pass through as-is.
|
|
82
|
+
return httpx.Response(
|
|
83
|
+
status_code=broker_resp.status_code,
|
|
84
|
+
headers=dict(broker_resp.headers),
|
|
85
|
+
content=broker_resp.content,
|
|
86
|
+
request=request,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def aclose(self):
|
|
90
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: x402-pay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pay for x402 APIs with 3 lines of Python — no wallet needed
|
|
5
|
+
Author-email: hugen <dev@hugen.tokyo>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hugen-tokyo/x402-pay
|
|
8
|
+
Project-URL: Documentation, https://discovery.hugen.tokyo/llms.txt
|
|
9
|
+
Project-URL: Issues, https://github.com/hugen-tokyo/x402-pay/issues
|
|
10
|
+
Keywords: x402,micropayments,api,pay-per-call,usdc
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Provides-Extra: wallet
|
|
24
|
+
Requires-Dist: x402[evm,httpx]>=2.1.0; extra == "wallet"
|
|
25
|
+
Requires-Dist: eth-account>=0.13; extra == "wallet"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# x402-pay
|
|
29
|
+
|
|
30
|
+
Call any x402 API with one API key. No per-API wallet integration needed.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from x402_pay import PayClient
|
|
34
|
+
|
|
35
|
+
async with PayClient(api_key="gw_your_topped_up_key") as client:
|
|
36
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
37
|
+
print(resp.json())
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Routes requests through a broker that handles on-chain payment on your behalf. $0.01/call.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install x402-pay
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Modes
|
|
49
|
+
|
|
50
|
+
### API Key Mode (default, no wallet)
|
|
51
|
+
|
|
52
|
+
Get a key and top it up first:
|
|
53
|
+
```bash
|
|
54
|
+
# Create key (free, gets $0.05 search credit)
|
|
55
|
+
curl -X POST https://discovery.hugen.tokyo/keys/create
|
|
56
|
+
# Top up to unlock broker ($1.00 x402 payment)
|
|
57
|
+
curl -X POST "https://discovery.hugen.tokyo/keys/topup?key=gw_YOUR_KEY"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Then use it:
|
|
61
|
+
```python
|
|
62
|
+
from x402_pay import PayClient
|
|
63
|
+
|
|
64
|
+
async with PayClient(api_key="gw_YOUR_KEY") as client:
|
|
65
|
+
# Any x402 API — broker pays upstream, deducts $0.01 from your key
|
|
66
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
67
|
+
print(resp.json())
|
|
68
|
+
|
|
69
|
+
# Check remaining balance
|
|
70
|
+
print(f"${await client.balance():.2f}")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Wallet Mode (power users)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install x402-pay[wallet]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from x402_pay import DirectClient
|
|
81
|
+
|
|
82
|
+
async with DirectClient(private_key="0x...") as client:
|
|
83
|
+
# Pay upstream directly from your wallet — no broker fee
|
|
84
|
+
resp = await client.get("https://weather.hugen.tokyo/weather/current?city=Tokyo")
|
|
85
|
+
print(resp.json())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
API keys are stored in `~/.x402-pay/config.json`. Override with environment variables:
|
|
91
|
+
|
|
92
|
+
| Variable | Default | Description |
|
|
93
|
+
|----------|---------|-------------|
|
|
94
|
+
| `X402_API_KEY` | auto-created | API key for broker mode |
|
|
95
|
+
| `X402_BROKER_URL` | `https://discovery.hugen.tokyo` | Broker endpoint |
|
|
96
|
+
| `X402_PAY_CONFIG_DIR` | `~/.x402-pay` | Config directory |
|
|
97
|
+
|
|
98
|
+
## API
|
|
99
|
+
|
|
100
|
+
### PayClient
|
|
101
|
+
|
|
102
|
+
- `PayClient(api_key="", broker_url="", timeout=60.0)` — create client
|
|
103
|
+
- `await client.get(url)` — GET request through broker
|
|
104
|
+
- `await client.post(url, json={...})` — POST request through broker
|
|
105
|
+
- `await client.balance()` — check key balance in USD
|
|
106
|
+
- `client.api_key` — current API key
|
|
107
|
+
|
|
108
|
+
### DirectClient
|
|
109
|
+
|
|
110
|
+
- `DirectClient(private_key="0x...")` — create wallet client
|
|
111
|
+
- `await client.get(url)` / `await client.post(url)` — direct x402 payment
|
|
112
|
+
|
|
113
|
+
### Exceptions
|
|
114
|
+
|
|
115
|
+
- `PayError` — base exception
|
|
116
|
+
- `InsufficientBalance` — key balance too low (`.balance`, `.needed`, `.topup_url`)
|
|
117
|
+
- `NotInCatalog` — URL not in x402 catalog (`.url`)
|
|
118
|
+
- `BrokerError` — upstream call failed (`.refunded`)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/x402_pay/__init__.py
|
|
5
|
+
src/x402_pay/client.py
|
|
6
|
+
src/x402_pay/config.py
|
|
7
|
+
src/x402_pay/direct.py
|
|
8
|
+
src/x402_pay/exceptions.py
|
|
9
|
+
src/x402_pay/keys.py
|
|
10
|
+
src/x402_pay/transport.py
|
|
11
|
+
src/x402_pay.egg-info/PKG-INFO
|
|
12
|
+
src/x402_pay.egg-info/SOURCES.txt
|
|
13
|
+
src/x402_pay.egg-info/dependency_links.txt
|
|
14
|
+
src/x402_pay.egg-info/requires.txt
|
|
15
|
+
src/x402_pay.egg-info/top_level.txt
|
|
16
|
+
tests/test_client.py
|
|
17
|
+
tests/test_keys.py
|
|
18
|
+
tests/test_transport.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
x402_pay
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tests for PayClient."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from x402_pay.client import PayClient
|
|
9
|
+
from x402_pay import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mock_broker():
|
|
14
|
+
"""Returns a mock transport handler and call log."""
|
|
15
|
+
calls = []
|
|
16
|
+
|
|
17
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
18
|
+
calls.append({
|
|
19
|
+
"method": request.method,
|
|
20
|
+
"url": str(request.url),
|
|
21
|
+
"body": json.loads(request.content) if request.content else None,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
url = str(request.url)
|
|
25
|
+
|
|
26
|
+
# Mock key creation
|
|
27
|
+
if "/keys/create" in url:
|
|
28
|
+
return httpx.Response(200, json={
|
|
29
|
+
"key": "gw_autotest1234567890abcdef",
|
|
30
|
+
"balance_usd": 0.05,
|
|
31
|
+
}, request=request)
|
|
32
|
+
|
|
33
|
+
# Mock balance check
|
|
34
|
+
if "/keys/balance" in url:
|
|
35
|
+
return httpx.Response(200, json={
|
|
36
|
+
"balance_usd": 0.04,
|
|
37
|
+
"total_spent": 0.01,
|
|
38
|
+
"total_calls": 1,
|
|
39
|
+
}, request=request)
|
|
40
|
+
|
|
41
|
+
# Mock broker call — returns upstream data directly
|
|
42
|
+
if "/broker/call" in url:
|
|
43
|
+
return httpx.Response(200, json={
|
|
44
|
+
"city": "Tokyo", "temp": 22,
|
|
45
|
+
}, headers={
|
|
46
|
+
"x-broker-cost": "0.01",
|
|
47
|
+
"x-broker-balance": "0.04",
|
|
48
|
+
}, request=request)
|
|
49
|
+
|
|
50
|
+
return httpx.Response(404, request=request)
|
|
51
|
+
|
|
52
|
+
return handler, calls
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_payclient_auto_key_creation(mock_broker):
|
|
57
|
+
"""PayClient auto-creates API key on first use."""
|
|
58
|
+
handler, calls = mock_broker
|
|
59
|
+
|
|
60
|
+
client = PayClient(broker_url="https://broker.test")
|
|
61
|
+
# Override transport internals
|
|
62
|
+
client._api_key = ""
|
|
63
|
+
await client._ensure_init()
|
|
64
|
+
|
|
65
|
+
# The key should have been auto-created
|
|
66
|
+
# (ensure_key calls /keys/create, which our mock handles)
|
|
67
|
+
# We need to patch the HTTP calls in the key module too
|
|
68
|
+
# For this test, just set key directly and test the flow
|
|
69
|
+
client._api_key = "gw_test_direct"
|
|
70
|
+
assert client.api_key == "gw_test_direct"
|
|
71
|
+
|
|
72
|
+
await client.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_payclient_get(mock_broker):
|
|
77
|
+
"""PayClient.get() routes through broker."""
|
|
78
|
+
handler, calls = mock_broker
|
|
79
|
+
|
|
80
|
+
client = PayClient(api_key="gw_test123", broker_url="https://broker.test")
|
|
81
|
+
# Replace transport's internal HTTP client
|
|
82
|
+
await client._ensure_init()
|
|
83
|
+
assert client._transport is not None
|
|
84
|
+
client._transport._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
85
|
+
|
|
86
|
+
resp = await client.get("https://weather.test/api?city=Tokyo")
|
|
87
|
+
assert resp.status_code == 200
|
|
88
|
+
data = resp.json()
|
|
89
|
+
assert data["city"] == "Tokyo"
|
|
90
|
+
|
|
91
|
+
# Verify broker was called
|
|
92
|
+
broker_calls = [c for c in calls if "/broker/call" in c["url"]]
|
|
93
|
+
assert len(broker_calls) == 1
|
|
94
|
+
assert broker_calls[0]["body"]["url"] == "https://weather.test/api?city=Tokyo"
|
|
95
|
+
|
|
96
|
+
await client.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_payclient_context_manager(mock_broker):
|
|
101
|
+
"""PayClient works as async context manager."""
|
|
102
|
+
handler, _ = mock_broker
|
|
103
|
+
|
|
104
|
+
async with PayClient(api_key="gw_test", broker_url="https://broker.test") as client:
|
|
105
|
+
client._transport._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
106
|
+
resp = await client.get("https://test.api/endpoint")
|
|
107
|
+
assert resp.status_code == 200
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_payclient_explicit_key():
|
|
112
|
+
"""Explicit API key skips auto-creation."""
|
|
113
|
+
client = PayClient(api_key="gw_explicit_key")
|
|
114
|
+
assert client.api_key == "gw_explicit_key"
|
|
115
|
+
await client.close()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tests for API key management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from x402_pay import config
|
|
9
|
+
from x402_pay.keys import create_key, get_balance, ensure_key
|
|
10
|
+
from x402_pay.exceptions import KeyError_, PayError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.asyncio
|
|
14
|
+
async def test_create_key():
|
|
15
|
+
"""Key creation calls broker and returns key data."""
|
|
16
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
17
|
+
assert "/keys/create" in str(request.url)
|
|
18
|
+
return httpx.Response(200, json={
|
|
19
|
+
"key": "gw_newkey1234",
|
|
20
|
+
"balance_usd": 0.05,
|
|
21
|
+
"trial_calls": 10,
|
|
22
|
+
}, request=request)
|
|
23
|
+
|
|
24
|
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
25
|
+
result = await create_key(label="test", client=client)
|
|
26
|
+
|
|
27
|
+
assert result["key"] == "gw_newkey1234"
|
|
28
|
+
assert result["balance_usd"] == 0.05
|
|
29
|
+
|
|
30
|
+
await client.aclose()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_get_balance():
|
|
35
|
+
"""Balance check returns key info."""
|
|
36
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
37
|
+
assert "/keys/balance" in str(request.url)
|
|
38
|
+
return httpx.Response(200, json={
|
|
39
|
+
"balance_usd": 0.04,
|
|
40
|
+
"total_spent": 0.01,
|
|
41
|
+
"total_calls": 1,
|
|
42
|
+
}, request=request)
|
|
43
|
+
|
|
44
|
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
45
|
+
result = await get_balance("gw_test123", client=client)
|
|
46
|
+
|
|
47
|
+
assert result["balance_usd"] == 0.04
|
|
48
|
+
assert result["total_calls"] == 1
|
|
49
|
+
|
|
50
|
+
await client.aclose()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_get_balance_no_key():
|
|
55
|
+
"""Balance check without key raises KeyError_."""
|
|
56
|
+
with pytest.raises(KeyError_):
|
|
57
|
+
await get_balance("")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_get_balance_404():
|
|
62
|
+
"""Balance check for nonexistent key raises KeyError_."""
|
|
63
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
64
|
+
return httpx.Response(404, json={"error": "Key not found."}, request=request)
|
|
65
|
+
|
|
66
|
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
67
|
+
with pytest.raises(KeyError_):
|
|
68
|
+
await get_balance("gw_nonexistent", client=client)
|
|
69
|
+
|
|
70
|
+
await client.aclose()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_ensure_key_creates_new(tmp_path, monkeypatch):
|
|
75
|
+
"""ensure_key auto-creates when no key exists."""
|
|
76
|
+
monkeypatch.setenv("X402_PAY_CONFIG_DIR", str(tmp_path / ".x402-pay"))
|
|
77
|
+
|
|
78
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
79
|
+
return httpx.Response(200, json={
|
|
80
|
+
"key": "gw_auto_created",
|
|
81
|
+
"balance_usd": 0.05,
|
|
82
|
+
}, request=request)
|
|
83
|
+
|
|
84
|
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
85
|
+
key = await ensure_key(client=client)
|
|
86
|
+
|
|
87
|
+
assert key == "gw_auto_created"
|
|
88
|
+
# Should be saved to config
|
|
89
|
+
assert config.get_api_key() == "gw_auto_created"
|
|
90
|
+
|
|
91
|
+
await client.aclose()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_ensure_key_uses_existing(monkeypatch):
|
|
96
|
+
"""ensure_key returns existing key without network call."""
|
|
97
|
+
monkeypatch.setenv("X402_API_KEY", "gw_env_key")
|
|
98
|
+
|
|
99
|
+
key = await ensure_key()
|
|
100
|
+
assert key == "gw_env_key"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tests for BrokerTransport."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from x402_pay.transport import BrokerTransport
|
|
9
|
+
from x402_pay.exceptions import InsufficientBalance, NotInCatalog, BrokerError, KeyError_
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeBrokerApp:
|
|
13
|
+
"""Mock broker that responds to POST /broker/call."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, responses: dict | None = None):
|
|
16
|
+
self.responses = responses or {}
|
|
17
|
+
self.calls: list[dict] = []
|
|
18
|
+
|
|
19
|
+
async def handle(self, request: httpx.Request) -> httpx.Response:
|
|
20
|
+
self.calls.append({
|
|
21
|
+
"method": request.method,
|
|
22
|
+
"url": str(request.url),
|
|
23
|
+
"headers": dict(request.headers),
|
|
24
|
+
"body": json.loads(request.content) if request.content else None,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
# Default success response — broker returns upstream data directly
|
|
28
|
+
target_url = ""
|
|
29
|
+
if request.content:
|
|
30
|
+
body = json.loads(request.content)
|
|
31
|
+
target_url = body.get("url", "")
|
|
32
|
+
|
|
33
|
+
if target_url in self.responses:
|
|
34
|
+
return self.responses[target_url]
|
|
35
|
+
|
|
36
|
+
return httpx.Response(
|
|
37
|
+
200,
|
|
38
|
+
json={"result": "ok"},
|
|
39
|
+
headers={"x-broker-cost": "0.01", "x-broker-balance": "0.04"},
|
|
40
|
+
request=request,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_transport_success():
|
|
46
|
+
"""Successful request through broker."""
|
|
47
|
+
app = FakeBrokerApp()
|
|
48
|
+
transport = BrokerTransport(api_key="gw_test123", broker_url="https://broker.test")
|
|
49
|
+
# Replace internal client with mock
|
|
50
|
+
transport._http = httpx.AsyncClient(transport=httpx.MockTransport(app.handle))
|
|
51
|
+
|
|
52
|
+
request = httpx.Request("GET", "https://weather.test/api?city=Tokyo")
|
|
53
|
+
response = await transport.handle_async_request(request)
|
|
54
|
+
|
|
55
|
+
assert response.status_code == 200
|
|
56
|
+
data = json.loads(response.content)
|
|
57
|
+
assert data["result"] == "ok"
|
|
58
|
+
|
|
59
|
+
# Verify broker was called correctly
|
|
60
|
+
assert len(app.calls) == 1
|
|
61
|
+
call = app.calls[0]
|
|
62
|
+
assert call["method"] == "POST"
|
|
63
|
+
assert call["headers"]["x-api-key"] == "gw_test123"
|
|
64
|
+
assert call["body"]["url"] == "https://weather.test/api?city=Tokyo"
|
|
65
|
+
assert call["body"]["method"] == "GET"
|
|
66
|
+
|
|
67
|
+
await transport.aclose()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_transport_401_invalid_key():
|
|
72
|
+
"""Invalid API key raises KeyError_."""
|
|
73
|
+
async def reject(request: httpx.Request) -> httpx.Response:
|
|
74
|
+
return httpx.Response(401, json={"error": "Invalid API key."}, request=request)
|
|
75
|
+
|
|
76
|
+
transport = BrokerTransport(api_key="bad_key")
|
|
77
|
+
transport._http = httpx.AsyncClient(transport=httpx.MockTransport(reject))
|
|
78
|
+
|
|
79
|
+
with pytest.raises(KeyError_):
|
|
80
|
+
await transport.handle_async_request(httpx.Request("GET", "https://test.api/x"))
|
|
81
|
+
|
|
82
|
+
await transport.aclose()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_transport_402_insufficient_balance():
|
|
87
|
+
"""Insufficient balance raises InsufficientBalance."""
|
|
88
|
+
async def low_balance(request: httpx.Request) -> httpx.Response:
|
|
89
|
+
return httpx.Response(
|
|
90
|
+
402,
|
|
91
|
+
json={"balance_usd": 0.002, "needed": 0.01, "topup": "https://topup.url"},
|
|
92
|
+
request=request,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
transport = BrokerTransport(api_key="gw_test")
|
|
96
|
+
transport._http = httpx.AsyncClient(transport=httpx.MockTransport(low_balance))
|
|
97
|
+
|
|
98
|
+
with pytest.raises(InsufficientBalance) as exc_info:
|
|
99
|
+
await transport.handle_async_request(httpx.Request("GET", "https://test.api/x"))
|
|
100
|
+
|
|
101
|
+
assert exc_info.value.balance == 0.002
|
|
102
|
+
assert exc_info.value.needed == 0.01
|
|
103
|
+
assert "topup" in exc_info.value.topup_url
|
|
104
|
+
|
|
105
|
+
await transport.aclose()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_transport_404_not_in_catalog():
|
|
110
|
+
"""Unknown URL raises NotInCatalog."""
|
|
111
|
+
async def not_found(request: httpx.Request) -> httpx.Response:
|
|
112
|
+
return httpx.Response(404, json={"error": "URL not in catalog."}, request=request)
|
|
113
|
+
|
|
114
|
+
transport = BrokerTransport(api_key="gw_test")
|
|
115
|
+
transport._http = httpx.AsyncClient(transport=httpx.MockTransport(not_found))
|
|
116
|
+
|
|
117
|
+
with pytest.raises(NotInCatalog) as exc_info:
|
|
118
|
+
await transport.handle_async_request(httpx.Request("GET", "https://unknown.api/x"))
|
|
119
|
+
|
|
120
|
+
assert "unknown.api" in exc_info.value.url
|
|
121
|
+
|
|
122
|
+
await transport.aclose()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_transport_502_upstream_error():
|
|
127
|
+
"""Upstream failure raises BrokerError with refund info."""
|
|
128
|
+
async def upstream_fail(request: httpx.Request) -> httpx.Response:
|
|
129
|
+
return httpx.Response(
|
|
130
|
+
502,
|
|
131
|
+
json={"error": "Upstream timeout", "refunded": True, "balance_remaining": 0.05},
|
|
132
|
+
request=request,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
transport = BrokerTransport(api_key="gw_test")
|
|
136
|
+
transport._http = httpx.AsyncClient(transport=httpx.MockTransport(upstream_fail))
|
|
137
|
+
|
|
138
|
+
with pytest.raises(BrokerError) as exc_info:
|
|
139
|
+
await transport.handle_async_request(httpx.Request("GET", "https://test.api/x"))
|
|
140
|
+
|
|
141
|
+
assert exc_info.value.refunded is True
|
|
142
|
+
|
|
143
|
+
await transport.aclose()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_transport_post_with_body():
|
|
148
|
+
"""POST request forwards body to broker."""
|
|
149
|
+
app = FakeBrokerApp()
|
|
150
|
+
transport = BrokerTransport(api_key="gw_test")
|
|
151
|
+
transport._http = httpx.AsyncClient(transport=httpx.MockTransport(app.handle))
|
|
152
|
+
|
|
153
|
+
request = httpx.Request("POST", "https://test.api/submit", content=json.dumps({"key": "value"}).encode())
|
|
154
|
+
await transport.handle_async_request(request)
|
|
155
|
+
|
|
156
|
+
assert app.calls[0]["body"]["method"] == "POST"
|
|
157
|
+
assert app.calls[0]["body"]["body"] == {"key": "value"}
|
|
158
|
+
|
|
159
|
+
await transport.aclose()
|