aifinpay-gate 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.
- aifinpay_gate-0.1.0/LICENSE +21 -0
- aifinpay_gate-0.1.0/PKG-INFO +154 -0
- aifinpay_gate-0.1.0/README.md +130 -0
- aifinpay_gate-0.1.0/aifinpay_gate/__init__.py +36 -0
- aifinpay_gate-0.1.0/aifinpay_gate/agents.py +30 -0
- aifinpay_gate-0.1.0/aifinpay_gate/asgi.py +87 -0
- aifinpay_gate-0.1.0/aifinpay_gate/challenge.py +62 -0
- aifinpay_gate-0.1.0/aifinpay_gate/core.py +375 -0
- aifinpay_gate-0.1.0/aifinpay_gate/discovery.py +39 -0
- aifinpay_gate-0.1.0/aifinpay_gate/pricing.py +29 -0
- aifinpay_gate-0.1.0/aifinpay_gate/scope.py +25 -0
- aifinpay_gate-0.1.0/aifinpay_gate/stores.py +108 -0
- aifinpay_gate-0.1.0/aifinpay_gate/verify.py +172 -0
- aifinpay_gate-0.1.0/aifinpay_gate/wsgi.py +74 -0
- aifinpay_gate-0.1.0/aifinpay_gate.egg-info/PKG-INFO +154 -0
- aifinpay_gate-0.1.0/aifinpay_gate.egg-info/SOURCES.txt +23 -0
- aifinpay_gate-0.1.0/aifinpay_gate.egg-info/dependency_links.txt +1 -0
- aifinpay_gate-0.1.0/aifinpay_gate.egg-info/requires.txt +4 -0
- aifinpay_gate-0.1.0/aifinpay_gate.egg-info/top_level.txt +1 -0
- aifinpay_gate-0.1.0/pyproject.toml +35 -0
- aifinpay_gate-0.1.0/setup.cfg +4 -0
- aifinpay_gate-0.1.0/tests/test_adapters.py +104 -0
- aifinpay_gate-0.1.0/tests/test_frameworks.py +50 -0
- aifinpay_gate-0.1.0/tests/test_gate.py +248 -0
- aifinpay_gate-0.1.0/tests/test_parity.py +59 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AiFinPay
|
|
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.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aifinpay-gate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Accept AIFP-1 payments from AI agents in a Python API — ASGI (FastAPI, Starlette) and WSGI (Flask, Django) middleware. Port of @aifinpay/gate.
|
|
5
|
+
Author-email: "CoinSecurities (SECCO)" <contact@aifinpay.io>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://aifinpay.io
|
|
8
|
+
Project-URL: Repository, https://github.com/AiFinPay/sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/AiFinPay/sdk/issues
|
|
10
|
+
Keywords: ai-agents,agent-payments,http-402,paywall,fastapi,flask,asgi,wsgi,middleware,aifinpay
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Flask
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: PyNaCl>=1.5
|
|
21
|
+
Provides-Extra: redis
|
|
22
|
+
Requires-Dist: redis>=4.2; extra == "redis"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# aifinpay-gate (Python)
|
|
26
|
+
|
|
27
|
+
Accept payments from AI agents in a Python API. When an agent calls a paid
|
|
28
|
+
route without paying, the gate answers **HTTP 402** with everything it needs to
|
|
29
|
+
buy a batch of calls; the agent settles on-chain from its own wallet (you
|
|
30
|
+
receive 99%, AiFinPay 1%, non-custodial) and retries with an `AIFP-Receipt`.
|
|
31
|
+
The gate verifies that receipt **locally** — an Ed25519 signature against the
|
|
32
|
+
AiFinPay JWKS, no call to AiFinPay per request — and meters the batch.
|
|
33
|
+
|
|
34
|
+
A port of [`@aifinpay/gate`](https://www.npmjs.com/package/@aifinpay/gate): the
|
|
35
|
+
same 402 body, the same checks and the same refusal sentences, held to the Node
|
|
36
|
+
package by recorded fixtures (`tests/test_parity.py`).
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install aifinpay-gate # PyNaCl is the only dependency
|
|
40
|
+
pip install "aifinpay-gate[redis]" # shared counters across processes
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Register
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
curl -s -X POST https://api.aifinpay.io/v1/merchants -H 'content-type: application/json' \
|
|
47
|
+
-d '{"name":"My API","pay_to":{"evm":"0xYourPayoutWallet"},"settlement_version":"1.4"}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Keep `merchant_id` and the one-time `merchant_secret`, then claim the merchant
|
|
51
|
+
at https://dash.aifinpay.io with the secret to see payments. One `pay_to.evm`
|
|
52
|
+
receives on every EVM network.
|
|
53
|
+
|
|
54
|
+
## FastAPI / Starlette (ASGI)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from fastapi import FastAPI, Request
|
|
58
|
+
from aifinpay_gate import AifpGateMiddleware, Gate, Route
|
|
59
|
+
|
|
60
|
+
gate = Gate("mrch_…", routes=[
|
|
61
|
+
Route("/create", "premium", methods={"POST"}), # $0.005 per call
|
|
62
|
+
Route("/api/*"), # the section and everything under it
|
|
63
|
+
])
|
|
64
|
+
app = FastAPI()
|
|
65
|
+
app.add_middleware(AifpGateMiddleware, gate=gate)
|
|
66
|
+
|
|
67
|
+
@app.post("/create")
|
|
68
|
+
async def create(request: Request):
|
|
69
|
+
paid = request.state.aifp # {"agent", "receipt_id", "used", "remaining", ...}
|
|
70
|
+
return {"ok": True, "remaining": paid["remaining"]}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Flask / Django (WSGI)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from aifinpay_gate import AifpGateWSGI, Gate, Route
|
|
77
|
+
|
|
78
|
+
app.wsgi_app = AifpGateWSGI(app.wsgi_app, Gate("mrch_…", routes=[Route("/api/*")]))
|
|
79
|
+
# in a view: request.environ["aifp"]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Both adapters also serve `/.well-known/x402.json` so agents discover the paid
|
|
83
|
+
routes before they hit one (`serve_discovery=False` to turn it off).
|
|
84
|
+
|
|
85
|
+
## Any other framework
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from aifinpay_gate import Gate, SimpleRequest
|
|
89
|
+
|
|
90
|
+
result = gate.decide(SimpleRequest(path, headers, method))
|
|
91
|
+
if result is None: # not a paid route
|
|
92
|
+
...
|
|
93
|
+
elif not result.ok: # 402 / 403 / 503 — send result.status, result.headers, result.body as JSON
|
|
94
|
+
...
|
|
95
|
+
else: # paid: add result.headers to your response; result.aifp is the metering context
|
|
96
|
+
...
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Routes
|
|
100
|
+
|
|
101
|
+
`Route(pattern, tier="standard", weight=None, methods=None, paywall=True)`
|
|
102
|
+
|
|
103
|
+
- `pattern` is exact, or ends in `/*` — `/api/*` covers `/api` and everything
|
|
104
|
+
under `/api/`. The longest matching pattern wins. This is the hosted
|
|
105
|
+
gateway's matcher; wildcards in the middle (`/backtest/*/bid`) are refused.
|
|
106
|
+
- `tier`: `standard` $0.0005, `complex` $0.002, `premium` $0.005 per call
|
|
107
|
+
(weights 1, 4, 10 billing units). `weight` overrides the units per call.
|
|
108
|
+
- `paywall=False` serves the route free and meters nothing.
|
|
109
|
+
- Paths no route matches pass through untouched.
|
|
110
|
+
|
|
111
|
+
`Gate(merchant_id, resource="/api/search", tier="complex")` is the single-mount
|
|
112
|
+
form: every request handed to it is charged against that one resource.
|
|
113
|
+
|
|
114
|
+
## Production: share the counters
|
|
115
|
+
|
|
116
|
+
The default `MemoryStore` meters per process. Under gunicorn/uvicorn with
|
|
117
|
+
several workers, or several pods, each gets a full copy of every batch — a
|
|
118
|
+
200-unit batch serves up to 200 × workers calls. Use Redis:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import redis
|
|
122
|
+
from aifinpay_gate import Gate, RedisStore
|
|
123
|
+
|
|
124
|
+
gate = Gate("mrch_…", routes=[...], store=RedisStore(redis.Redis.from_url(REDIS_URL)))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The store increments atomically and compares **after** the increment, so
|
|
128
|
+
concurrent requests can never overspend a batch; the counter's TTL is set once
|
|
129
|
+
and expires with the receipt.
|
|
130
|
+
|
|
131
|
+
## Options
|
|
132
|
+
|
|
133
|
+
| Option | Default | |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `should_charge` | everyone pays | `known_ai_agent` for content sites: human browsers read free and never see a 402; self-identifying AI crawlers and anything speaking AIFP pay. A predicate that raises charges. |
|
|
136
|
+
| `allow(ctx)` | — | Your veto after verification, before metering (a refused call costs nothing). Return `False` → 403. |
|
|
137
|
+
| `on_store_error` | `"closed"` | `"open"` serves un-metered calls when the store is down (`AIFP-Meter: degraded`). |
|
|
138
|
+
| `require_agent_match` | `False` | Refuse when `AIFP-Agent-Id` differs from the receipt subject (anti-accident, not anti-theft). |
|
|
139
|
+
| `replay` | `"auto"` | Single-use receipts get a one-shot nonce check. |
|
|
140
|
+
| `jwks` | fetched | Pin the key set and skip network I/O (redeploy on key rotation). |
|
|
141
|
+
| `on_event(e)` | — | `402` / `serve` / `403` / `meter_error` events for your metrics. |
|
|
142
|
+
| `refund_on_error` (adapters) | `False` | Give the units back when your handler answers 5xx. |
|
|
143
|
+
|
|
144
|
+
## What it guarantees
|
|
145
|
+
|
|
146
|
+
- Fails **closed**: if the JWKS cannot be reached, paid routes answer 503, never free.
|
|
147
|
+
- Only EdDSA receipts from `https://api.aifinpay.io` with `aud` = your merchant id.
|
|
148
|
+
- Only quota receipts are spendable; per-call billing receipts are refused.
|
|
149
|
+
- An expired receipt gets a 402 (buy again), a wrong one a 403 with a reason.
|
|
150
|
+
- Stricter than the Node gate in two places: a receipt without a numeric `exp`,
|
|
151
|
+
or without a usable `unit_quota`, is refused.
|
|
152
|
+
|
|
153
|
+
Not included, on purpose: free allowances, daily caps and per-agent blocks live
|
|
154
|
+
in the AiFinPay dashboard, so there is one source of truth for each rule.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# aifinpay-gate (Python)
|
|
2
|
+
|
|
3
|
+
Accept payments from AI agents in a Python API. When an agent calls a paid
|
|
4
|
+
route without paying, the gate answers **HTTP 402** with everything it needs to
|
|
5
|
+
buy a batch of calls; the agent settles on-chain from its own wallet (you
|
|
6
|
+
receive 99%, AiFinPay 1%, non-custodial) and retries with an `AIFP-Receipt`.
|
|
7
|
+
The gate verifies that receipt **locally** — an Ed25519 signature against the
|
|
8
|
+
AiFinPay JWKS, no call to AiFinPay per request — and meters the batch.
|
|
9
|
+
|
|
10
|
+
A port of [`@aifinpay/gate`](https://www.npmjs.com/package/@aifinpay/gate): the
|
|
11
|
+
same 402 body, the same checks and the same refusal sentences, held to the Node
|
|
12
|
+
package by recorded fixtures (`tests/test_parity.py`).
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install aifinpay-gate # PyNaCl is the only dependency
|
|
16
|
+
pip install "aifinpay-gate[redis]" # shared counters across processes
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Register
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
curl -s -X POST https://api.aifinpay.io/v1/merchants -H 'content-type: application/json' \
|
|
23
|
+
-d '{"name":"My API","pay_to":{"evm":"0xYourPayoutWallet"},"settlement_version":"1.4"}'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Keep `merchant_id` and the one-time `merchant_secret`, then claim the merchant
|
|
27
|
+
at https://dash.aifinpay.io with the secret to see payments. One `pay_to.evm`
|
|
28
|
+
receives on every EVM network.
|
|
29
|
+
|
|
30
|
+
## FastAPI / Starlette (ASGI)
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from fastapi import FastAPI, Request
|
|
34
|
+
from aifinpay_gate import AifpGateMiddleware, Gate, Route
|
|
35
|
+
|
|
36
|
+
gate = Gate("mrch_…", routes=[
|
|
37
|
+
Route("/create", "premium", methods={"POST"}), # $0.005 per call
|
|
38
|
+
Route("/api/*"), # the section and everything under it
|
|
39
|
+
])
|
|
40
|
+
app = FastAPI()
|
|
41
|
+
app.add_middleware(AifpGateMiddleware, gate=gate)
|
|
42
|
+
|
|
43
|
+
@app.post("/create")
|
|
44
|
+
async def create(request: Request):
|
|
45
|
+
paid = request.state.aifp # {"agent", "receipt_id", "used", "remaining", ...}
|
|
46
|
+
return {"ok": True, "remaining": paid["remaining"]}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Flask / Django (WSGI)
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from aifinpay_gate import AifpGateWSGI, Gate, Route
|
|
53
|
+
|
|
54
|
+
app.wsgi_app = AifpGateWSGI(app.wsgi_app, Gate("mrch_…", routes=[Route("/api/*")]))
|
|
55
|
+
# in a view: request.environ["aifp"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Both adapters also serve `/.well-known/x402.json` so agents discover the paid
|
|
59
|
+
routes before they hit one (`serve_discovery=False` to turn it off).
|
|
60
|
+
|
|
61
|
+
## Any other framework
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from aifinpay_gate import Gate, SimpleRequest
|
|
65
|
+
|
|
66
|
+
result = gate.decide(SimpleRequest(path, headers, method))
|
|
67
|
+
if result is None: # not a paid route
|
|
68
|
+
...
|
|
69
|
+
elif not result.ok: # 402 / 403 / 503 — send result.status, result.headers, result.body as JSON
|
|
70
|
+
...
|
|
71
|
+
else: # paid: add result.headers to your response; result.aifp is the metering context
|
|
72
|
+
...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Routes
|
|
76
|
+
|
|
77
|
+
`Route(pattern, tier="standard", weight=None, methods=None, paywall=True)`
|
|
78
|
+
|
|
79
|
+
- `pattern` is exact, or ends in `/*` — `/api/*` covers `/api` and everything
|
|
80
|
+
under `/api/`. The longest matching pattern wins. This is the hosted
|
|
81
|
+
gateway's matcher; wildcards in the middle (`/backtest/*/bid`) are refused.
|
|
82
|
+
- `tier`: `standard` $0.0005, `complex` $0.002, `premium` $0.005 per call
|
|
83
|
+
(weights 1, 4, 10 billing units). `weight` overrides the units per call.
|
|
84
|
+
- `paywall=False` serves the route free and meters nothing.
|
|
85
|
+
- Paths no route matches pass through untouched.
|
|
86
|
+
|
|
87
|
+
`Gate(merchant_id, resource="/api/search", tier="complex")` is the single-mount
|
|
88
|
+
form: every request handed to it is charged against that one resource.
|
|
89
|
+
|
|
90
|
+
## Production: share the counters
|
|
91
|
+
|
|
92
|
+
The default `MemoryStore` meters per process. Under gunicorn/uvicorn with
|
|
93
|
+
several workers, or several pods, each gets a full copy of every batch — a
|
|
94
|
+
200-unit batch serves up to 200 × workers calls. Use Redis:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import redis
|
|
98
|
+
from aifinpay_gate import Gate, RedisStore
|
|
99
|
+
|
|
100
|
+
gate = Gate("mrch_…", routes=[...], store=RedisStore(redis.Redis.from_url(REDIS_URL)))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The store increments atomically and compares **after** the increment, so
|
|
104
|
+
concurrent requests can never overspend a batch; the counter's TTL is set once
|
|
105
|
+
and expires with the receipt.
|
|
106
|
+
|
|
107
|
+
## Options
|
|
108
|
+
|
|
109
|
+
| Option | Default | |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `should_charge` | everyone pays | `known_ai_agent` for content sites: human browsers read free and never see a 402; self-identifying AI crawlers and anything speaking AIFP pay. A predicate that raises charges. |
|
|
112
|
+
| `allow(ctx)` | — | Your veto after verification, before metering (a refused call costs nothing). Return `False` → 403. |
|
|
113
|
+
| `on_store_error` | `"closed"` | `"open"` serves un-metered calls when the store is down (`AIFP-Meter: degraded`). |
|
|
114
|
+
| `require_agent_match` | `False` | Refuse when `AIFP-Agent-Id` differs from the receipt subject (anti-accident, not anti-theft). |
|
|
115
|
+
| `replay` | `"auto"` | Single-use receipts get a one-shot nonce check. |
|
|
116
|
+
| `jwks` | fetched | Pin the key set and skip network I/O (redeploy on key rotation). |
|
|
117
|
+
| `on_event(e)` | — | `402` / `serve` / `403` / `meter_error` events for your metrics. |
|
|
118
|
+
| `refund_on_error` (adapters) | `False` | Give the units back when your handler answers 5xx. |
|
|
119
|
+
|
|
120
|
+
## What it guarantees
|
|
121
|
+
|
|
122
|
+
- Fails **closed**: if the JWKS cannot be reached, paid routes answer 503, never free.
|
|
123
|
+
- Only EdDSA receipts from `https://api.aifinpay.io` with `aud` = your merchant id.
|
|
124
|
+
- Only quota receipts are spendable; per-call billing receipts are refused.
|
|
125
|
+
- An expired receipt gets a 402 (buy again), a wrong one a 403 with a reason.
|
|
126
|
+
- Stricter than the Node gate in two places: a receipt without a numeric `exp`,
|
|
127
|
+
or without a usable `unit_quota`, is refused.
|
|
128
|
+
|
|
129
|
+
Not included, on purpose: free allowances, daily caps and per-agent blocks live
|
|
130
|
+
in the AiFinPay dashboard, so there is one source of truth for each rule.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""AiFinPay gate for Python merchants — accept AIFP-1 payments from AI agents.
|
|
2
|
+
|
|
3
|
+
A port of ``@aifinpay/gate``: the same 402 challenge, the same local EdDSA
|
|
4
|
+
receipt verification against the AiFinPay JWKS, the same post-increment quota
|
|
5
|
+
metering. See README.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .agents import AI_AGENT_UA_MARKERS, known_ai_agent
|
|
9
|
+
from .asgi import AifpGateMiddleware
|
|
10
|
+
from .challenge import build_challenge
|
|
11
|
+
from .core import (
|
|
12
|
+
DETAIL_QUOTA_EXHAUSTED,
|
|
13
|
+
DETAIL_RECEIPT_EXPIRED,
|
|
14
|
+
DETAIL_VERIFY_FAILED,
|
|
15
|
+
HEADER_QUOTA_REMAINING,
|
|
16
|
+
Gate,
|
|
17
|
+
GateResult,
|
|
18
|
+
Route,
|
|
19
|
+
SimpleRequest,
|
|
20
|
+
)
|
|
21
|
+
from .discovery import build_discovery_document
|
|
22
|
+
from .pricing import TIER_WEIGHTS, UNIT_PRICE_USD, min_requests_for_tier, unit_price_usd, weight_for_tier
|
|
23
|
+
from .scope import pattern_covers, scope_covers
|
|
24
|
+
from .stores import REDIS_INCRBY_SCRIPT, MemoryStore, RedisStore, StoreCapacityError
|
|
25
|
+
from .verify import Verifier
|
|
26
|
+
from .wsgi import AifpGateWSGI
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AI_AGENT_UA_MARKERS", "AifpGateMiddleware", "AifpGateWSGI", "DETAIL_QUOTA_EXHAUSTED", "DETAIL_RECEIPT_EXPIRED",
|
|
32
|
+
"DETAIL_VERIFY_FAILED", "Gate", "GateResult", "HEADER_QUOTA_REMAINING", "MemoryStore", "REDIS_INCRBY_SCRIPT",
|
|
33
|
+
"RedisStore", "Route", "SimpleRequest", "StoreCapacityError", "TIER_WEIGHTS", "UNIT_PRICE_USD", "Verifier",
|
|
34
|
+
"build_challenge", "build_discovery_document", "known_ai_agent", "min_requests_for_tier", "pattern_covers",
|
|
35
|
+
"scope_covers", "unit_price_usd", "weight_for_tier",
|
|
36
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Who is an AI agent — the default ``should_charge`` for content sites.
|
|
2
|
+
|
|
3
|
+
A curated list of self-identifying crawlers plus one stronger signal: a
|
|
4
|
+
request already speaking AIFP (AIFP-Receipt or AIFP-Agent-Id) is an agent by
|
|
5
|
+
its own declaration. It is not stealth detection; a scraper with a browser
|
|
6
|
+
User-Agent walks past it, as it walks past Cloudflare's classifier. Extend it:
|
|
7
|
+
|
|
8
|
+
should_charge=lambda req: known_ai_agent(req) or my_signal(req)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
AI_AGENT_UA_MARKERS = (
|
|
12
|
+
"gptbot", "oai-searchbot", "chatgpt-user",
|
|
13
|
+
"claudebot", "claude-web", "anthropic-ai",
|
|
14
|
+
"perplexitybot", "perplexity-user",
|
|
15
|
+
"ccbot", "bytespider", "google-extended", "applebot-extended",
|
|
16
|
+
"meta-externalagent", "facebookbot", "amazonbot",
|
|
17
|
+
"youbot", "diffbot", "timpibot", "omgilibot", "cohere-ai", "ai2bot", "mistralai",
|
|
18
|
+
"aifinpay-agent",
|
|
19
|
+
"headlesschrome", "phantomjs",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def known_ai_agent(req) -> bool:
|
|
24
|
+
# A paying agent must never be exempted by a human-looking User-Agent.
|
|
25
|
+
if req.header("AIFP-Receipt") or req.header("AIFP-Agent-Id"):
|
|
26
|
+
return True
|
|
27
|
+
ua = (req.header("user-agent") or "").lower()
|
|
28
|
+
if not ua:
|
|
29
|
+
return True # no browser sends none; scripts do
|
|
30
|
+
return any(marker in ua for marker in AI_AGENT_UA_MARKERS)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""ASGI middleware — FastAPI, Starlette, Quart, Litestar, any ASGI app.
|
|
2
|
+
|
|
3
|
+
from aifinpay_gate import Gate, Route, AifpGateMiddleware
|
|
4
|
+
gate = Gate("mrch_…", routes=[Route("/create", "premium", methods={"POST"})])
|
|
5
|
+
app.add_middleware(AifpGateMiddleware, gate=gate)
|
|
6
|
+
|
|
7
|
+
Paid calls reach the handler with the metering context in
|
|
8
|
+
``request.state.aifp`` (Starlette) — i.e. ``scope["state"]["aifp"]``. A gate
|
|
9
|
+
decision is always a response, never an exception: a 402 routed through an
|
|
10
|
+
app's error handler would come out as a 500, and an agent cannot pay a 500.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from .core import Gate, GateResult
|
|
18
|
+
from .discovery import DISCOVERY_PATH, build_discovery_document
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _AsgiRequest:
|
|
22
|
+
def __init__(self, scope: Dict[str, Any]):
|
|
23
|
+
self.path = scope.get("path") or "/"
|
|
24
|
+
self.method = scope.get("method", "GET")
|
|
25
|
+
self._headers: Dict[str, str] = {}
|
|
26
|
+
for k, v in scope.get("headers") or []:
|
|
27
|
+
name = k.decode("latin-1").lower()
|
|
28
|
+
self._headers.setdefault(name, v.decode("latin-1"))
|
|
29
|
+
|
|
30
|
+
def header(self, name: str) -> Optional[str]:
|
|
31
|
+
return self._headers.get(name.lower())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _send_json(send, status: int, headers: Dict[str, str], body: Any) -> None:
|
|
35
|
+
raw = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
|
|
36
|
+
hdrs = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items() if k.lower() != "content-type"]
|
|
37
|
+
hdrs += [(b"content-type", b"application/json"), (b"content-length", str(len(raw)).encode())]
|
|
38
|
+
await send({"type": "http.response.start", "status": status, "headers": hdrs})
|
|
39
|
+
await send({"type": "http.response.body", "body": raw})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AifpGateMiddleware:
|
|
43
|
+
def __init__(self, app, gate: Gate, serve_discovery: bool = True, refund_on_error: bool = False):
|
|
44
|
+
self.app = app
|
|
45
|
+
self.gate = gate
|
|
46
|
+
self.serve_discovery = serve_discovery
|
|
47
|
+
self.refund_on_error = refund_on_error
|
|
48
|
+
self._discovery = build_discovery_document(gate.merchant_id, gate.discovery_resources(),
|
|
49
|
+
gate.api_base or "https://api.aifinpay.io")
|
|
50
|
+
|
|
51
|
+
async def __call__(self, scope, receive, send):
|
|
52
|
+
if scope.get("type") != "http":
|
|
53
|
+
return await self.app(scope, receive, send)
|
|
54
|
+
req = _AsgiRequest(scope)
|
|
55
|
+
if self.serve_discovery and req.method == "GET" and req.path == DISCOVERY_PATH:
|
|
56
|
+
return await _send_json(send, 200, {"Cache-Control": "public, max-age=300"}, self._discovery)
|
|
57
|
+
# decide() may block on a JWKS fetch or a Redis round-trip; keep that
|
|
58
|
+
# off the event loop.
|
|
59
|
+
try:
|
|
60
|
+
result: Optional[GateResult] = await asyncio.get_running_loop().run_in_executor(
|
|
61
|
+
None, self.gate.decide, req
|
|
62
|
+
)
|
|
63
|
+
except Exception: # noqa: BLE001 — a bug in this package, not a payment decision
|
|
64
|
+
if self.gate.on_store_error == "open":
|
|
65
|
+
return await self.app(scope, receive, send)
|
|
66
|
+
return await _send_json(send, 503, {}, {"error": "AIFP-503-METER",
|
|
67
|
+
"detail": "payment gate unavailable — retry shortly"})
|
|
68
|
+
if result is None:
|
|
69
|
+
return await self.app(scope, receive, send)
|
|
70
|
+
if not result.ok:
|
|
71
|
+
return await _send_json(send, result.status, result.headers, result.body)
|
|
72
|
+
|
|
73
|
+
scope.setdefault("state", {})["aifp"] = result.aifp
|
|
74
|
+
extra = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in result.headers.items()]
|
|
75
|
+
status_seen = {}
|
|
76
|
+
|
|
77
|
+
async def send_with_headers(message):
|
|
78
|
+
if message["type"] == "http.response.start":
|
|
79
|
+
status_seen["status"] = message.get("status", 200)
|
|
80
|
+
message = {**message, "headers": list(message.get("headers") or []) + extra}
|
|
81
|
+
await send(message)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
await self.app(scope, receive, send_with_headers)
|
|
85
|
+
finally:
|
|
86
|
+
if self.refund_on_error and status_seen.get("status", 500) >= 500:
|
|
87
|
+
self.gate.refund(result.aifp)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The 402 body, key for key what @aifinpay/gate and the hosted gateway emit.
|
|
2
|
+
|
|
3
|
+
It is the only documentation an agent gets before any relationship exists, so
|
|
4
|
+
it carries the whole purchase path. tests/test_parity.py compares it with the
|
|
5
|
+
Node package's output, byte for byte."""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from .pricing import PROTOCOL_FEE_BPS, min_requests_for_tier, unit_price_usd
|
|
10
|
+
|
|
11
|
+
DEFAULT_API_BASE = "https://api.aifinpay.io"
|
|
12
|
+
DEFAULT_DETAIL = "Payment Required — prepay a batch of requests and retry with the AIFP-Receipt header"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_challenge(
|
|
16
|
+
merchant_id: str,
|
|
17
|
+
resource: str,
|
|
18
|
+
tier: str,
|
|
19
|
+
weight: int,
|
|
20
|
+
detail: Optional[str] = None,
|
|
21
|
+
scope: Optional[str] = None,
|
|
22
|
+
unit_price: Optional[str] = None,
|
|
23
|
+
min_requests: Optional[int] = None,
|
|
24
|
+
api_base: Optional[str] = None,
|
|
25
|
+
) -> Dict[str, Any]:
|
|
26
|
+
# Absolute on purpose: this gate runs on the merchant's host, so a relative
|
|
27
|
+
# "/v1/quote" would send the agent to the merchant's own server.
|
|
28
|
+
api = (api_base or DEFAULT_API_BASE).rstrip("/")
|
|
29
|
+
scope = scope or "exact"
|
|
30
|
+
requests_ = min_requests if min_requests is not None else min_requests_for_tier(tier)
|
|
31
|
+
return {
|
|
32
|
+
"error": "AIFP-402",
|
|
33
|
+
"detail": detail or DEFAULT_DETAIL,
|
|
34
|
+
"protocol": "AIFP-1",
|
|
35
|
+
"documentation_url": "https://github.com/AiFinPay/sdk/blob/main/AGENT-FLOW.md",
|
|
36
|
+
"instructions_url": "https://raw.githubusercontent.com/AiFinPay/skill/main/agent/skills/aifinpay/SKILL.md",
|
|
37
|
+
"merchant_instructions_url":
|
|
38
|
+
"https://raw.githubusercontent.com/AiFinPay/skill/main/agent/skills/aifinpay-merchant/SKILL.md",
|
|
39
|
+
"merchant_id": merchant_id,
|
|
40
|
+
"resource": resource,
|
|
41
|
+
"tier": tier,
|
|
42
|
+
"unit_weight": weight,
|
|
43
|
+
"unit_price_usd": unit_price or unit_price_usd(tier),
|
|
44
|
+
"min_requests": requests_,
|
|
45
|
+
"scope": scope,
|
|
46
|
+
"protocol_fee_bps": PROTOCOL_FEE_BPS,
|
|
47
|
+
"no_minimum_fee": True,
|
|
48
|
+
"settlement_terms_from":
|
|
49
|
+
f"POST {api}/v1/quote — returns accepted_chains, accepted_assets, amount, order_id and expiry",
|
|
50
|
+
"how_to_pay": [
|
|
51
|
+
f'POST {api}/v1/quote {{"merchant_id":"{merchant_id}","resource":"{resource}","tier":"{tier}",'
|
|
52
|
+
f'"scope":"{scope}","requests":{requests_},"payer":"<your wallet address>"}} — payer is required; '
|
|
53
|
+
"raise requests to buy more than the minimum batch; check amount, scope, expiry and payer before paying",
|
|
54
|
+
"settle the quoted batch on-chain from your own wallet (order_id = quote_id)",
|
|
55
|
+
f"POST {api}/v1/pay {{quote_id, chain, asset, tx_ref, payment_authorization}} + Idempotency-Key header "
|
|
56
|
+
"-> quota receipt (wallet-signature-v1). Keep the quote until you hold the receipt; on a timeout retry "
|
|
57
|
+
"with the same key and tx_ref — never pay again",
|
|
58
|
+
"retry this request with header: AIFP-Receipt: <receipt JWT>",
|
|
59
|
+
],
|
|
60
|
+
"no_wallet": "npx @aifinpay/mcp init — creates or selects a local wallet; read instructions_url for supported "
|
|
61
|
+
"clients and payment availability. Wallet setup alone does not enable settlement.",
|
|
62
|
+
}
|