agent-framework-botsrated 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.
- agent_framework_botsrated-0.1.0/.gitignore +6 -0
- agent_framework_botsrated-0.1.0/LICENSE +21 -0
- agent_framework_botsrated-0.1.0/PKG-INFO +127 -0
- agent_framework_botsrated-0.1.0/README.md +108 -0
- agent_framework_botsrated-0.1.0/catalog-pr/PR_BODY.md +38 -0
- agent_framework_botsrated-0.1.0/examples/quickstart.py +82 -0
- agent_framework_botsrated-0.1.0/pyproject.toml +45 -0
- agent_framework_botsrated-0.1.0/src/agent_framework_botsrated/__init__.py +28 -0
- agent_framework_botsrated-0.1.0/src/agent_framework_botsrated/_middleware.py +168 -0
- agent_framework_botsrated-0.1.0/src/agent_framework_botsrated/py.typed +0 -0
- agent_framework_botsrated-0.1.0/tests/conftest.py +37 -0
- agent_framework_botsrated-0.1.0/tests/test_live.py +74 -0
- agent_framework_botsrated-0.1.0/tests/test_middleware.py +180 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BotsRated
|
|
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,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-framework-botsrated
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BotsRated pre-payment check for Microsoft Agent Framework: function middleware that checks an x402 payTo with the free BotsRated preflight before a payment tool runs.
|
|
5
|
+
Project-URL: Homepage, https://botsrated.com
|
|
6
|
+
Project-URL: Repository, https://github.com/botsrated/botsrated
|
|
7
|
+
Project-URL: Documentation, https://github.com/botsrated/botsrated/blob/main/integrations/microsoft-agent-framework/README.md
|
|
8
|
+
Project-URL: Issues, https://github.com/botsrated/botsrated/issues
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-framework,base,botsrated,microsoft-agent-framework,middleware,payments,usdc,x402
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: agent-framework-core<2,>=1.22
|
|
14
|
+
Requires-Dist: botsrated<0.2,>=0.1.2
|
|
15
|
+
Provides-Extra: mcp
|
|
16
|
+
Requires-Dist: mcp<3,>=1.24; extra == 'mcp'
|
|
17
|
+
Requires-Dist: tenacity<10,>=9.1.4; extra == 'mcp'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# agent-framework-botsrated: BotsRated pre-payment check for Microsoft Agent Framework (Python)
|
|
21
|
+
|
|
22
|
+
Function middleware for [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) that checks an
|
|
23
|
+
x402 payment with [BotsRated](https://botsrated.com) **before a payment tool runs**. BotsRated probes x402 sellers on
|
|
24
|
+
Base and records which `payTo` wallet each one actually quoted. The middleware asks BotsRated's free preflight
|
|
25
|
+
(no API key, no wallet) whether the `payTo` your agent is about to pay was observed for that seller, and stops the call
|
|
26
|
+
if it wasn't.
|
|
27
|
+
|
|
28
|
+
Maintained by BotsRated, not by Microsoft. Issues: https://github.com/botsrated/botsrated/issues/new
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install "agent-framework-botsrated @ git+https://github.com/botsrated/botsrated#subdirectory=integrations/microsoft-agent-framework"
|
|
34
|
+
# with the hosted MCP server example:
|
|
35
|
+
pip install "agent-framework-botsrated[mcp] @ git+https://github.com/botsrated/botsrated#subdirectory=integrations/microsoft-agent-framework"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+, `agent-framework-core>=1.22,<2` and [`botsrated`](https://pypi.org/project/botsrated/) 0.1.2+.
|
|
39
|
+
Tested with agent-framework-core 1.22.0 (with mcp 1.30) and 1.23.0 (with mcp 2.3). agent-framework-core 1.22 can't
|
|
40
|
+
initialize MCP sessions with mcp 2.x, so pin `mcp<2` there if you use the hosted MCP server.
|
|
41
|
+
|
|
42
|
+
## Middleware: check payments before they run
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from agent_framework import Agent
|
|
46
|
+
from agent_framework_botsrated import BotsRatedPaymentMiddleware
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pay_x402(url: str, pay_to: str, amount: str, network: str = "eip155:8453") -> str:
|
|
50
|
+
"""Pay an x402 seller."""
|
|
51
|
+
... # your payment code
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
agent = Agent(
|
|
55
|
+
client=chat_client,
|
|
56
|
+
tools=[pay_x402],
|
|
57
|
+
middleware=[BotsRatedPaymentMiddleware(["pay_x402"])],
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- Only the functions you name are checked. Every other function call passes through untouched.
|
|
62
|
+
- The quote is read from the call's arguments: `url`, `pay_to` (or `payTo`), `amount` (atomic units) and optional
|
|
63
|
+
`network` (CAIP-2, default `eip155:8453`). For other argument names pass an extractor:
|
|
64
|
+
`BotsRatedPaymentMiddleware({"buy": lambda a: PaymentQuote(url=a["endpoint"], pay_to=a["recipient"], amount=str(a["atomic"]))})`.
|
|
65
|
+
- A blocked call never reaches `call_next`. The reason (with a link to the BotsRated probe receipt) becomes the call's
|
|
66
|
+
result, so the model reads it. With `on_block="raise"` the run ends with `BotsRatedPaymentBlocked` (a
|
|
67
|
+
`MiddlewareFailure`).
|
|
68
|
+
- The `botsrated.Decision` is stored in `context.metadata["botsrated_decision"]`; `guard.on_decision` is also called.
|
|
69
|
+
|
|
70
|
+
Default policy (from `botsrated.GuardPolicy`, configurable through `guard=PrePaymentGuard(policy=...)`):
|
|
71
|
+
|
|
72
|
+
| Situation | Result |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `payTo` never observed by BotsRated for that host (same network) | **blocked** (`payto_mismatch`) |
|
|
75
|
+
| Quote can't be read from the arguments | **blocked** (`quote_unreadable`) |
|
|
76
|
+
| Price above the last observed price on the same route | allowed with a warning |
|
|
77
|
+
| Host not covered, or data older than 24 h | allowed (info) |
|
|
78
|
+
| BotsRated unreachable | allowed (`fail_open=True`); set `PrePaymentGuard(fail_open=False)` to block |
|
|
79
|
+
|
|
80
|
+
The middleware checks what the payment tool was asked to pay. If your tool fetches the 402 itself and signs it, also
|
|
81
|
+
register `botsrated.PrePaymentGuard().x402_hook` on your `x402Client`'s `on_before_payment_creation` so the check
|
|
82
|
+
runs on the exact quote being signed.
|
|
83
|
+
|
|
84
|
+
## Tool: let the model check a payment option
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from agent_framework_botsrated import botsrated_check_payment_tool
|
|
88
|
+
|
|
89
|
+
agent = Agent(client=chat_client, tools=[botsrated_check_payment_tool(), pay_x402])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`botsrated_check_payment(url, pay_to, amount, network)` returns JSON with `action` (`allow`/`block`), `reasons` and
|
|
93
|
+
`receipt_url`. It uses the free preflight only.
|
|
94
|
+
|
|
95
|
+
## Hosted MCP server (no package needed)
|
|
96
|
+
|
|
97
|
+
BotsRated also runs a remote MCP server (streamable HTTP, no auth). Its free tools are `preflight`, `coverage`,
|
|
98
|
+
`sample` and `history`:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from agent_framework import MCPStreamableHTTPTool
|
|
102
|
+
|
|
103
|
+
botsrated_mcp = MCPStreamableHTTPTool(
|
|
104
|
+
name="botsrated",
|
|
105
|
+
url="https://botsrated.com/mcp",
|
|
106
|
+
allowed_tools=["preflight", "coverage", "sample", "history"],
|
|
107
|
+
)
|
|
108
|
+
async with botsrated_mcp:
|
|
109
|
+
result = await Agent(client=chat_client, tools=[botsrated_mcp]).run("Is blockrun.ai a known x402 seller?")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The paid lookups (`check` $0.001, `report` $0.002, `best` $0.01 USDC on Base; the first 100 checks and 10 reports per
|
|
113
|
+
client per day are free) are not used by this package unless you pass a paid-enabled `BotsRatedClient` to the guard.
|
|
114
|
+
|
|
115
|
+
## Run the example and tests
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install -e ".[mcp]" pytest pytest-asyncio
|
|
119
|
+
python examples/quickstart.py # scripted model, real free preflight, stub payment
|
|
120
|
+
pytest -m "not live" # offline
|
|
121
|
+
pytest -m live # real free BotsRated preflight + hosted MCP server; nothing is paid
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`examples/quickstart.py` uses a scripted chat client instead of a model, so it runs without an API key. One payment
|
|
125
|
+
call uses an unknown `payTo` and is blocked; the other uses the `payTo` BotsRated observed for blockrun.ai and runs.
|
|
126
|
+
|
|
127
|
+
BotsRated results are measurements, not endorsements. MIT licensed.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# agent-framework-botsrated: BotsRated pre-payment check for Microsoft Agent Framework (Python)
|
|
2
|
+
|
|
3
|
+
Function middleware for [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) that checks an
|
|
4
|
+
x402 payment with [BotsRated](https://botsrated.com) **before a payment tool runs**. BotsRated probes x402 sellers on
|
|
5
|
+
Base and records which `payTo` wallet each one actually quoted. The middleware asks BotsRated's free preflight
|
|
6
|
+
(no API key, no wallet) whether the `payTo` your agent is about to pay was observed for that seller, and stops the call
|
|
7
|
+
if it wasn't.
|
|
8
|
+
|
|
9
|
+
Maintained by BotsRated, not by Microsoft. Issues: https://github.com/botsrated/botsrated/issues/new
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install "agent-framework-botsrated @ git+https://github.com/botsrated/botsrated#subdirectory=integrations/microsoft-agent-framework"
|
|
15
|
+
# with the hosted MCP server example:
|
|
16
|
+
pip install "agent-framework-botsrated[mcp] @ git+https://github.com/botsrated/botsrated#subdirectory=integrations/microsoft-agent-framework"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python 3.10+, `agent-framework-core>=1.22,<2` and [`botsrated`](https://pypi.org/project/botsrated/) 0.1.2+.
|
|
20
|
+
Tested with agent-framework-core 1.22.0 (with mcp 1.30) and 1.23.0 (with mcp 2.3). agent-framework-core 1.22 can't
|
|
21
|
+
initialize MCP sessions with mcp 2.x, so pin `mcp<2` there if you use the hosted MCP server.
|
|
22
|
+
|
|
23
|
+
## Middleware: check payments before they run
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from agent_framework import Agent
|
|
27
|
+
from agent_framework_botsrated import BotsRatedPaymentMiddleware
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pay_x402(url: str, pay_to: str, amount: str, network: str = "eip155:8453") -> str:
|
|
31
|
+
"""Pay an x402 seller."""
|
|
32
|
+
... # your payment code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
agent = Agent(
|
|
36
|
+
client=chat_client,
|
|
37
|
+
tools=[pay_x402],
|
|
38
|
+
middleware=[BotsRatedPaymentMiddleware(["pay_x402"])],
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- Only the functions you name are checked. Every other function call passes through untouched.
|
|
43
|
+
- The quote is read from the call's arguments: `url`, `pay_to` (or `payTo`), `amount` (atomic units) and optional
|
|
44
|
+
`network` (CAIP-2, default `eip155:8453`). For other argument names pass an extractor:
|
|
45
|
+
`BotsRatedPaymentMiddleware({"buy": lambda a: PaymentQuote(url=a["endpoint"], pay_to=a["recipient"], amount=str(a["atomic"]))})`.
|
|
46
|
+
- A blocked call never reaches `call_next`. The reason (with a link to the BotsRated probe receipt) becomes the call's
|
|
47
|
+
result, so the model reads it. With `on_block="raise"` the run ends with `BotsRatedPaymentBlocked` (a
|
|
48
|
+
`MiddlewareFailure`).
|
|
49
|
+
- The `botsrated.Decision` is stored in `context.metadata["botsrated_decision"]`; `guard.on_decision` is also called.
|
|
50
|
+
|
|
51
|
+
Default policy (from `botsrated.GuardPolicy`, configurable through `guard=PrePaymentGuard(policy=...)`):
|
|
52
|
+
|
|
53
|
+
| Situation | Result |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `payTo` never observed by BotsRated for that host (same network) | **blocked** (`payto_mismatch`) |
|
|
56
|
+
| Quote can't be read from the arguments | **blocked** (`quote_unreadable`) |
|
|
57
|
+
| Price above the last observed price on the same route | allowed with a warning |
|
|
58
|
+
| Host not covered, or data older than 24 h | allowed (info) |
|
|
59
|
+
| BotsRated unreachable | allowed (`fail_open=True`); set `PrePaymentGuard(fail_open=False)` to block |
|
|
60
|
+
|
|
61
|
+
The middleware checks what the payment tool was asked to pay. If your tool fetches the 402 itself and signs it, also
|
|
62
|
+
register `botsrated.PrePaymentGuard().x402_hook` on your `x402Client`'s `on_before_payment_creation` so the check
|
|
63
|
+
runs on the exact quote being signed.
|
|
64
|
+
|
|
65
|
+
## Tool: let the model check a payment option
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from agent_framework_botsrated import botsrated_check_payment_tool
|
|
69
|
+
|
|
70
|
+
agent = Agent(client=chat_client, tools=[botsrated_check_payment_tool(), pay_x402])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`botsrated_check_payment(url, pay_to, amount, network)` returns JSON with `action` (`allow`/`block`), `reasons` and
|
|
74
|
+
`receipt_url`. It uses the free preflight only.
|
|
75
|
+
|
|
76
|
+
## Hosted MCP server (no package needed)
|
|
77
|
+
|
|
78
|
+
BotsRated also runs a remote MCP server (streamable HTTP, no auth). Its free tools are `preflight`, `coverage`,
|
|
79
|
+
`sample` and `history`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from agent_framework import MCPStreamableHTTPTool
|
|
83
|
+
|
|
84
|
+
botsrated_mcp = MCPStreamableHTTPTool(
|
|
85
|
+
name="botsrated",
|
|
86
|
+
url="https://botsrated.com/mcp",
|
|
87
|
+
allowed_tools=["preflight", "coverage", "sample", "history"],
|
|
88
|
+
)
|
|
89
|
+
async with botsrated_mcp:
|
|
90
|
+
result = await Agent(client=chat_client, tools=[botsrated_mcp]).run("Is blockrun.ai a known x402 seller?")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The paid lookups (`check` $0.001, `report` $0.002, `best` $0.01 USDC on Base; the first 100 checks and 10 reports per
|
|
94
|
+
client per day are free) are not used by this package unless you pass a paid-enabled `BotsRatedClient` to the guard.
|
|
95
|
+
|
|
96
|
+
## Run the example and tests
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install -e ".[mcp]" pytest pytest-asyncio
|
|
100
|
+
python examples/quickstart.py # scripted model, real free preflight, stub payment
|
|
101
|
+
pytest -m "not live" # offline
|
|
102
|
+
pytest -m live # real free BotsRated preflight + hosted MCP server; nothing is paid
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`examples/quickstart.py` uses a scripted chat client instead of a model, so it runs without an API key. One payment
|
|
106
|
+
call uses an unknown `payTo` and is blocked; the other uses the `payTo` BotsRated observed for blockrun.ai and runs.
|
|
107
|
+
|
|
108
|
+
BotsRated results are measurements, not endorsements. MIT licensed.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
### Motivation & Context
|
|
2
|
+
|
|
3
|
+
I work on BotsRated (https://botsrated.com), so I'm affiliated with the project this row lists. BotsRated probes paid x402 sellers on Base and records the payTo wallet each one quotes. This PR adds our Agent Framework integration to the Middleware section of the Python catalog.
|
|
4
|
+
|
|
5
|
+
The integration is published for the Python SDK. `agent-framework-botsrated` 0.1.0 is in our public MIT repository at https://github.com/botsrated/botsrated/tree/main/integrations/microsoft-agent-framework. You install it with `pip install "agent-framework-botsrated @ git+https://github.com/botsrated/botsrated#subdirectory=integrations/microsoft-agent-framework"`. It isn't on PyPI yet; its dependency [`botsrated`](https://pypi.org/project/botsrated/) is. The package works like this:
|
|
6
|
+
|
|
7
|
+
- `BotsRatedPaymentMiddleware` subclasses `FunctionMiddleware` and only checks the payment functions you name. It reads the quote (url, pay_to, amount, network) from the call's arguments, or uses an extractor you supply. It then asks BotsRated's free preflight (no key, no wallet) whether that payTo was observed for the seller.
|
|
8
|
+
- If the payTo wasn't observed, or the quote can't be read, the call never reaches `call_next`. The reason becomes the call's result so the model reads it. With `on_block="raise"`, the run ends with a `MiddlewareFailure` subclass instead. The decision is stored in `context.metadata["botsrated_decision"]`.
|
|
9
|
+
- `botsrated_check_payment_tool()` is a `FunctionTool` the model can call to check a payment option itself.
|
|
10
|
+
|
|
11
|
+
The row links to the integration's README, and issues go to https://github.com/botsrated/botsrated/issues/new. I'm not listing our hosted MCP server, because the catalog excludes standard MCP servers.
|
|
12
|
+
|
|
13
|
+
Testing: 17 tests ran with agent-framework-core 1.23.0 and 1.22.0, using a scripted chat client built on `FunctionInvocationLayer` and `ChatMiddlewareLayer`, so no model API key was needed. 13 tests run offline. 4 tests are live and call the real free BotsRated preflight and the hosted MCP server. A payment call with an unknown payTo was blocked without running, and a call with the payTo BotsRated observed for the seller ran. The payment function is a stub, so nothing was paid. To reproduce in a minute with no API key, clone the repository, `pip install -e integrations/microsoft-agent-framework`, then run `python integrations/microsoft-agent-framework/examples/quickstart.py`.
|
|
14
|
+
|
|
15
|
+
### Description & Review Guide
|
|
16
|
+
|
|
17
|
+
- **What are the major changes?** One row in the Middleware section of `python/samples/community-projects.md`, in alphabetical order between ACGS and Tracekit.
|
|
18
|
+
- **What is the impact of these changes?** Documentation only.
|
|
19
|
+
- **What do you want reviewers to focus on?** The wording of the row. I'm happy to change it or move it.
|
|
20
|
+
|
|
21
|
+
### Related Issue
|
|
22
|
+
|
|
23
|
+
N/A - trivial change: one catalog row in the format the catalog criteria define, no code.
|
|
24
|
+
|
|
25
|
+
### AI Assistance
|
|
26
|
+
|
|
27
|
+
- [ ] No material AI assistance was used.
|
|
28
|
+
- [x] This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.
|
|
29
|
+
|
|
30
|
+
AI assistance details: an AI assistant drafted the catalog row, this description, and the linked integration's code, tests and README. I tested them as described above.
|
|
31
|
+
|
|
32
|
+
### Contribution Checklist
|
|
33
|
+
|
|
34
|
+
- [ ] The code builds clean without any errors or warnings
|
|
35
|
+
- [ ] All unit tests pass, and I have added new tests where possible
|
|
36
|
+
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
|
|
37
|
+
- [x] This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
|
|
38
|
+
- [x] **This is not a breaking change.** If it _is_ a breaking change, add the `breaking change` label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Check x402 payments with BotsRated before a payment tool runs.
|
|
2
|
+
|
|
3
|
+
Runs without a model API key: a scripted chat client stands in for the model. It calls the
|
|
4
|
+
real, free BotsRated preflight (no key, no wallet). Nothing is paid; ``pay_x402`` below is a stub.
|
|
5
|
+
|
|
6
|
+
pip install agent-framework-botsrated # or: pip install -e .
|
|
7
|
+
python examples/quickstart.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Awaitable, Mapping, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from agent_framework import (
|
|
16
|
+
Agent,
|
|
17
|
+
BaseChatClient,
|
|
18
|
+
ChatMiddlewareLayer,
|
|
19
|
+
ChatResponse,
|
|
20
|
+
ChatResponseUpdate,
|
|
21
|
+
Content,
|
|
22
|
+
FunctionInvocationLayer,
|
|
23
|
+
Message,
|
|
24
|
+
ResponseStream,
|
|
25
|
+
)
|
|
26
|
+
from botsrated import BotsRatedClient
|
|
27
|
+
|
|
28
|
+
from agent_framework_botsrated import DECISION_METADATA_KEY, BotsRatedPaymentMiddleware
|
|
29
|
+
|
|
30
|
+
SELLER = "https://blockrun.ai/api/v1/chat/completions"
|
|
31
|
+
UNKNOWN_PAY_TO = "0x000000000000000000000000000000000000dEaD"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ScriptedModel(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
|
35
|
+
"""Stands in for a real chat client. Each turn is a list of (call_id, function, arguments)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, *turns: list[tuple[str, str, dict[str, Any]]]) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.turns = list(turns)
|
|
40
|
+
|
|
41
|
+
def _inner_get_response(
|
|
42
|
+
self, *, messages: Sequence[Message], stream: bool = False, options: Mapping[str, Any], **kwargs: Any
|
|
43
|
+
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
|
44
|
+
turn = self.turns.pop(0) if self.turns else []
|
|
45
|
+
calls = [Content.from_function_call(call_id=i, name=n, arguments=json.dumps(a)) for i, n, a in turn]
|
|
46
|
+
|
|
47
|
+
async def respond() -> ChatResponse:
|
|
48
|
+
return ChatResponse(messages=[Message(role="assistant", contents=calls or [Content.from_text("Done.")])])
|
|
49
|
+
|
|
50
|
+
return respond()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pay_x402(url: str, pay_to: str, amount: str, network: str = "eip155:8453") -> str:
|
|
54
|
+
"""Pay an x402 seller (stub: a real tool would sign and send the payment here)."""
|
|
55
|
+
return f"PAID {amount} to {pay_to} for {url}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def main() -> None:
|
|
59
|
+
observed_pay_to = BotsRatedClient().preflight(SELLER).body["latest_probe"]["live_payTo"]
|
|
60
|
+
model = ScriptedModel(
|
|
61
|
+
[
|
|
62
|
+
("pay-unknown", "pay_x402", {"url": SELLER, "pay_to": UNKNOWN_PAY_TO, "amount": "2000"}),
|
|
63
|
+
("pay-observed", "pay_x402", {"url": SELLER, "pay_to": observed_pay_to, "amount": "2000"}),
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
decisions = []
|
|
67
|
+
middleware = BotsRatedPaymentMiddleware(["pay_x402"])
|
|
68
|
+
middleware.guard.on_decision = decisions.append
|
|
69
|
+
agent = Agent(client=model, tools=[pay_x402], middleware=[middleware])
|
|
70
|
+
|
|
71
|
+
response = await agent.run("Buy one completion from blockrun.ai.")
|
|
72
|
+
for message in response.messages:
|
|
73
|
+
for content in message.contents:
|
|
74
|
+
if content.type == "function_result":
|
|
75
|
+
print(f"{content.call_id}: {content.result}")
|
|
76
|
+
for d in decisions:
|
|
77
|
+
print(f"decision for {d.host}: {d.action} {[r.code for r in d.reasons]}")
|
|
78
|
+
assert DECISION_METADATA_KEY == "botsrated_decision"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-framework-botsrated"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "BotsRated pre-payment check for Microsoft Agent Framework: function middleware that checks an x402 payTo with the free BotsRated preflight before a payment tool runs."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["agent-framework-core>=1.22,<2", "botsrated>=0.1.2,<0.2"]
|
|
9
|
+
keywords = ["agent-framework", "microsoft-agent-framework", "middleware", "x402", "botsrated", "payments", "base", "usdc"]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
mcp = ["mcp>=1.24,<3", "tenacity>=9.1.4,<10"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://botsrated.com"
|
|
16
|
+
Repository = "https://github.com/botsrated/botsrated"
|
|
17
|
+
Documentation = "https://github.com/botsrated/botsrated/blob/main/integrations/microsoft-agent-framework/README.md"
|
|
18
|
+
Issues = "https://github.com/botsrated/botsrated/issues"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/agent_framework_botsrated"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
markers = ["live: calls the real, free BotsRated API (no payment)"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 120
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
39
|
+
|
|
40
|
+
[tool.mypy]
|
|
41
|
+
strict = true
|
|
42
|
+
|
|
43
|
+
[[tool.mypy.overrides]]
|
|
44
|
+
module = ["botsrated", "botsrated.*"]
|
|
45
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""BotsRated pre-payment checks for Microsoft Agent Framework (Python)."""
|
|
2
|
+
|
|
3
|
+
from agent_framework_botsrated._middleware import (
|
|
4
|
+
DECISION_METADATA_KEY,
|
|
5
|
+
BotsRatedPaymentBlocked,
|
|
6
|
+
BotsRatedPaymentMiddleware,
|
|
7
|
+
PaymentQuote,
|
|
8
|
+
QuoteExtractor,
|
|
9
|
+
botsrated_check_payment_tool,
|
|
10
|
+
decision_to_dict,
|
|
11
|
+
default_quote_extractor,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
BOTSRATED_MCP_URL = "https://botsrated.com/mcp"
|
|
15
|
+
"""BotsRated's remote MCP server (streamable HTTP, no auth). Free tools: preflight, coverage, sample, history."""
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BOTSRATED_MCP_URL",
|
|
19
|
+
"DECISION_METADATA_KEY",
|
|
20
|
+
"BotsRatedPaymentBlocked",
|
|
21
|
+
"BotsRatedPaymentMiddleware",
|
|
22
|
+
"PaymentQuote",
|
|
23
|
+
"QuoteExtractor",
|
|
24
|
+
"botsrated_check_payment_tool",
|
|
25
|
+
"decision_to_dict",
|
|
26
|
+
"default_quote_extractor",
|
|
27
|
+
]
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""BotsRated pre-payment check for Microsoft Agent Framework function tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Awaitable, Callable, Collection, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Annotated, Any, Literal
|
|
10
|
+
|
|
11
|
+
from agent_framework import FunctionInvocationContext, FunctionMiddleware, FunctionTool, MiddlewareFailure, tool
|
|
12
|
+
from botsrated import BotsRatedClient, Decision, PrePaymentGuard, Reason
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
DECISION_METADATA_KEY = "botsrated_decision"
|
|
16
|
+
"""Key of the ``botsrated.Decision`` stored in ``FunctionInvocationContext.metadata`` for checked calls."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_NETWORK = "eip155:8453"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class PaymentQuote:
|
|
23
|
+
"""The x402 payment option a payment tool is about to pay."""
|
|
24
|
+
|
|
25
|
+
url: str
|
|
26
|
+
pay_to: str
|
|
27
|
+
amount: str
|
|
28
|
+
network: str = DEFAULT_NETWORK
|
|
29
|
+
scheme: str = "exact"
|
|
30
|
+
|
|
31
|
+
def requirements(self) -> dict[str, str]:
|
|
32
|
+
return {"scheme": self.scheme, "network": self.network, "payTo": self.pay_to, "amount": self.amount}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
QuoteExtractor = Callable[[Mapping[str, Any]], PaymentQuote]
|
|
36
|
+
"""Reads the payment quote from a payment tool's arguments. Raise any exception if it can't."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _first(args: Mapping[str, Any], *names: str) -> Any:
|
|
40
|
+
for name in names:
|
|
41
|
+
value = args.get(name)
|
|
42
|
+
if value not in (None, ""):
|
|
43
|
+
return value
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_quote_extractor(args: Mapping[str, Any]) -> PaymentQuote:
|
|
48
|
+
"""Read ``url``, ``pay_to`` (or ``payTo``), ``amount`` and optional ``network``/``scheme`` arguments."""
|
|
49
|
+
url = _first(args, "url", "resource_url", "resource")
|
|
50
|
+
pay_to = _first(args, "pay_to", "payTo")
|
|
51
|
+
amount = _first(args, "amount", "amount_atomic", "max_amount_required", "maxAmountRequired")
|
|
52
|
+
if not isinstance(url, str) or not isinstance(pay_to, str) or amount is None:
|
|
53
|
+
missing = [n for n, v in (("url", url), ("pay_to", pay_to), ("amount", amount)) if v is None]
|
|
54
|
+
raise ValueError(f"payment arguments are missing or not text: {', '.join(missing) or 'url/pay_to'}")
|
|
55
|
+
if isinstance(amount, bool) or not str(amount).isdigit():
|
|
56
|
+
raise ValueError("amount must be an integer number of atomic units")
|
|
57
|
+
network = _first(args, "network") or DEFAULT_NETWORK
|
|
58
|
+
scheme = _first(args, "scheme") or "exact"
|
|
59
|
+
return PaymentQuote(url=url, pay_to=pay_to, amount=str(amount), network=str(network), scheme=str(scheme))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BotsRatedPaymentBlocked(MiddlewareFailure):
|
|
63
|
+
"""Raised for a blocked payment when the middleware uses ``on_block="raise"``. It ends the run."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, decision: Decision) -> None:
|
|
66
|
+
super().__init__(decision.summary())
|
|
67
|
+
self.decision = decision
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _as_mapping(arguments: BaseModel | Mapping[str, Any]) -> Mapping[str, Any]:
|
|
71
|
+
if isinstance(arguments, BaseModel):
|
|
72
|
+
return arguments.model_dump(mode="json")
|
|
73
|
+
return arguments
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BotsRatedPaymentMiddleware(FunctionMiddleware):
|
|
77
|
+
"""Function middleware that checks an x402 payment with BotsRated before a payment tool runs.
|
|
78
|
+
|
|
79
|
+
Only the functions named in ``payment_tools`` are checked; every other call passes through
|
|
80
|
+
untouched. For a payment tool, the middleware reads the quote (URL, payTo, amount, network)
|
|
81
|
+
from the call's arguments and asks BotsRated's free preflight (no key, no wallet) whether
|
|
82
|
+
that payTo was observed for the seller. A blocked call never reaches ``call_next``: the
|
|
83
|
+
reason becomes the call's result, so the model reads it, or with ``on_block="raise"`` the
|
|
84
|
+
run ends with ``BotsRatedPaymentBlocked``.
|
|
85
|
+
|
|
86
|
+
Default policy (from ``botsrated.GuardPolicy``): a payTo that BotsRated has not seen for the
|
|
87
|
+
host is blocked; an uncovered host or stale data is allowed; a price above the observed one
|
|
88
|
+
is a warning. If BotsRated can't be reached, the payment is allowed (``fail_open=True`` on
|
|
89
|
+
the guard). If the quote can't be read from the arguments, the call is blocked.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
payment_tools: Names of the payment functions to check, or a mapping from name to a
|
|
93
|
+
``QuoteExtractor`` (``None`` uses ``default_quote_extractor``).
|
|
94
|
+
guard: A ``botsrated.PrePaymentGuard``. Defaults to a free-only guard (nothing is paid).
|
|
95
|
+
on_block: ``"result"`` (default) returns the reason as the call's result; ``"raise"`` ends the run.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
payment_tools: Collection[str] | Mapping[str, QuoteExtractor | None],
|
|
101
|
+
*,
|
|
102
|
+
guard: PrePaymentGuard | None = None,
|
|
103
|
+
on_block: Literal["result", "raise"] = "result",
|
|
104
|
+
) -> None:
|
|
105
|
+
if isinstance(payment_tools, str):
|
|
106
|
+
payment_tools = [payment_tools]
|
|
107
|
+
if isinstance(payment_tools, Mapping):
|
|
108
|
+
self.extractors: dict[str, QuoteExtractor] = {
|
|
109
|
+
name: extractor or default_quote_extractor for name, extractor in payment_tools.items()
|
|
110
|
+
}
|
|
111
|
+
else:
|
|
112
|
+
self.extractors = {name: default_quote_extractor for name in payment_tools}
|
|
113
|
+
if not self.extractors:
|
|
114
|
+
raise ValueError("payment_tools must name at least one payment function")
|
|
115
|
+
self.guard = guard or PrePaymentGuard(BotsRatedClient())
|
|
116
|
+
self.on_block = on_block
|
|
117
|
+
|
|
118
|
+
async def process(
|
|
119
|
+
self,
|
|
120
|
+
context: FunctionInvocationContext,
|
|
121
|
+
call_next: Callable[[], Awaitable[None]],
|
|
122
|
+
) -> None:
|
|
123
|
+
extractor = self.extractors.get(context.function.name)
|
|
124
|
+
if extractor is None:
|
|
125
|
+
await call_next()
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
quote = extractor(_as_mapping(context.arguments))
|
|
129
|
+
except Exception as error: # noqa: BLE001 - any unreadable quote fails closed
|
|
130
|
+
decision = Decision("block", None, [Reason("quote_unreadable", str(error), "block")])
|
|
131
|
+
else:
|
|
132
|
+
decision = await asyncio.to_thread(self.guard.decide, quote.url, quote.requirements())
|
|
133
|
+
context.metadata[DECISION_METADATA_KEY] = decision
|
|
134
|
+
if self.guard.on_decision:
|
|
135
|
+
self.guard.on_decision(decision)
|
|
136
|
+
if decision.action == "allow":
|
|
137
|
+
await call_next()
|
|
138
|
+
return
|
|
139
|
+
if self.on_block == "raise":
|
|
140
|
+
raise BotsRatedPaymentBlocked(decision)
|
|
141
|
+
context.result = f"Payment tool '{context.function.name}' was not executed. {decision.summary()}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def decision_to_dict(decision: Decision) -> dict[str, Any]:
|
|
145
|
+
return {
|
|
146
|
+
"action": decision.action,
|
|
147
|
+
"host": decision.host,
|
|
148
|
+
"reasons": [{"code": r.code, "detail": r.detail, "effect": r.effect} for r in decision.reasons],
|
|
149
|
+
"receipt_url": decision.facts.receipt_url if decision.facts else None,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def botsrated_check_payment_tool(guard: PrePaymentGuard | None = None) -> FunctionTool:
|
|
154
|
+
"""A function tool the model can call to check an x402 payment option before paying it (free preflight)."""
|
|
155
|
+
checker = guard or PrePaymentGuard(BotsRatedClient())
|
|
156
|
+
|
|
157
|
+
async def botsrated_check_payment(
|
|
158
|
+
url: Annotated[str, Field(description="The seller URL you are about to pay.")],
|
|
159
|
+
pay_to: Annotated[str, Field(description="The payTo address from the seller's 402 payment option.")],
|
|
160
|
+
amount: Annotated[str, Field(description="The amount in atomic units from that option.")],
|
|
161
|
+
network: Annotated[str, Field(description="The CAIP-2 network of that option.")] = DEFAULT_NETWORK,
|
|
162
|
+
) -> str:
|
|
163
|
+
"""Check an x402 payment option against BotsRated's measurements BEFORE paying it."""
|
|
164
|
+
quote = PaymentQuote(url=url, pay_to=pay_to, amount=amount, network=network)
|
|
165
|
+
decision = await asyncio.to_thread(checker.decide, quote.url, quote.requirements())
|
|
166
|
+
return json.dumps(decision_to_dict(decision))
|
|
167
|
+
|
|
168
|
+
return tool(botsrated_check_payment)
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Awaitable, Mapping, Sequence
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from agent_framework import (
|
|
6
|
+
BaseChatClient,
|
|
7
|
+
ChatMiddlewareLayer,
|
|
8
|
+
ChatResponse,
|
|
9
|
+
ChatResponseUpdate,
|
|
10
|
+
Content,
|
|
11
|
+
FunctionInvocationLayer,
|
|
12
|
+
Message,
|
|
13
|
+
ResponseStream,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScriptedModel(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
|
18
|
+
"""A stand-in chat client. Each turn is a list of (call_id, function, arguments)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *turns: list[tuple[str, str, dict[str, Any]]]) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.turns = list(turns)
|
|
23
|
+
|
|
24
|
+
def _inner_get_response(
|
|
25
|
+
self, *, messages: Sequence[Message], stream: bool = False, options: Mapping[str, Any], **kwargs: Any
|
|
26
|
+
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
|
27
|
+
turn = self.turns.pop(0) if self.turns else []
|
|
28
|
+
calls = [Content.from_function_call(call_id=i, name=n, arguments=json.dumps(a)) for i, n, a in turn]
|
|
29
|
+
|
|
30
|
+
async def respond() -> ChatResponse:
|
|
31
|
+
return ChatResponse(messages=[Message(role="assistant", contents=calls or [Content.from_text("Done.")])])
|
|
32
|
+
|
|
33
|
+
return respond()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def results(response: Any) -> dict[str, Any]:
|
|
37
|
+
return {c.call_id: c.result for m in response.messages for c in m.contents if c.type == "function_result"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Live tests: they call the real, free BotsRated preflight (REST and MCP). Nothing is paid.
|
|
2
|
+
|
|
3
|
+
pytest -m live
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from agent_framework import Agent, MCPStreamableHTTPTool
|
|
10
|
+
from botsrated import BotsRatedClient
|
|
11
|
+
from conftest import ScriptedModel, results
|
|
12
|
+
|
|
13
|
+
from agent_framework_botsrated import BOTSRATED_MCP_URL, BotsRatedPaymentMiddleware, botsrated_check_payment_tool
|
|
14
|
+
|
|
15
|
+
pytestmark = pytest.mark.live
|
|
16
|
+
|
|
17
|
+
SELLER = "https://blockrun.ai/api/v1/chat/completions"
|
|
18
|
+
UNKNOWN = "0x000000000000000000000000000000000000dEaD"
|
|
19
|
+
FREE_TOOLS = {"preflight", "coverage", "sample", "history"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def observed_pay_to() -> str:
|
|
23
|
+
pre = BotsRatedClient().preflight(SELLER)
|
|
24
|
+
assert pre.found, "blockrun.ai should be covered by BotsRated"
|
|
25
|
+
return pre.body["latest_probe"]["live_payTo"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
paid: list[str] = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pay_x402(url: str, pay_to: str, amount: str, network: str = "eip155:8453") -> str:
|
|
32
|
+
"""Pay an x402 seller (stub: nothing is paid)."""
|
|
33
|
+
paid.append(pay_to)
|
|
34
|
+
return "PAID"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_middleware_against_live_preflight() -> None:
|
|
38
|
+
good = observed_pay_to()
|
|
39
|
+
model = ScriptedModel(
|
|
40
|
+
[
|
|
41
|
+
("bad", "pay_x402", {"url": SELLER, "pay_to": UNKNOWN, "amount": "2000"}),
|
|
42
|
+
("good", "pay_x402", {"url": SELLER, "pay_to": good, "amount": "2000"}),
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
agent = Agent(client=model, tools=[pay_x402], middleware=[BotsRatedPaymentMiddleware(["pay_x402"])])
|
|
46
|
+
out = results(await agent.run("pay"))
|
|
47
|
+
assert paid == [good]
|
|
48
|
+
assert "payto_mismatch" in str(out["bad"]) and "receipt" in str(out["bad"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_check_payment_tool_live() -> None:
|
|
52
|
+
t = botsrated_check_payment_tool()
|
|
53
|
+
blocked = json.loads((await t.invoke(url=SELLER, pay_to=UNKNOWN, amount="2000"))[0].text)
|
|
54
|
+
assert blocked["action"] == "block" and blocked["reasons"][0]["code"] == "payto_mismatch"
|
|
55
|
+
allowed = json.loads((await t.invoke(url=SELLER, pay_to=observed_pay_to(), amount="2000"))[0].text)
|
|
56
|
+
assert allowed["action"] == "allow", allowed
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def test_hosted_mcp_server_free_tools() -> None:
|
|
60
|
+
async with MCPStreamableHTTPTool(name="botsrated", url=BOTSRATED_MCP_URL, allowed_tools=FREE_TOOLS) as mcp:
|
|
61
|
+
names = {f.name for f in mcp.functions}
|
|
62
|
+
assert names == FREE_TOOLS
|
|
63
|
+
preflight = next(f for f in mcp.functions if f.name == "preflight")
|
|
64
|
+
out = await preflight.invoke(url="blockrun.ai")
|
|
65
|
+
text = " ".join(str(getattr(c, "text", None) or c) for c in out)
|
|
66
|
+
assert "blockrun.ai" in text and '"found":true' in text.replace(" ", "")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def test_agent_calls_hosted_preflight_through_mcp() -> None:
|
|
70
|
+
mcp = MCPStreamableHTTPTool(name="botsrated", url=BOTSRATED_MCP_URL, allowed_tools=FREE_TOOLS)
|
|
71
|
+
model = ScriptedModel([("pf", "preflight", {"url": "blockrun.ai"})])
|
|
72
|
+
async with mcp:
|
|
73
|
+
out = results(await Agent(client=model, tools=[mcp]).run("Is blockrun.ai known?"))
|
|
74
|
+
assert "blockrun.ai" in str(out["pf"])
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Offline tests: the BotsRated guard is replaced by a recorder, so no network is used."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from agent_framework import Agent
|
|
5
|
+
from botsrated import Decision, GuardPolicy, PrePaymentGuard, Reason, SellerFacts, evaluate_payment
|
|
6
|
+
from conftest import ScriptedModel, results
|
|
7
|
+
|
|
8
|
+
from agent_framework_botsrated import (
|
|
9
|
+
DECISION_METADATA_KEY,
|
|
10
|
+
BotsRatedPaymentBlocked,
|
|
11
|
+
BotsRatedPaymentMiddleware,
|
|
12
|
+
PaymentQuote,
|
|
13
|
+
default_quote_extractor,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
SELLER = "https://seller.example/api"
|
|
17
|
+
GOOD = "0x1111111111111111111111111111111111111111"
|
|
18
|
+
BAD = "0x2222222222222222222222222222222222222222"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RecordingGuard(PrePaymentGuard):
|
|
22
|
+
"""Uses the real botsrated policy on fixed facts instead of calling the API."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.calls: list[tuple[str | None, dict]] = []
|
|
27
|
+
self.facts = SellerFacts(covered=True, host="seller.example", source="preflight", known_pay_tos=[GOOD])
|
|
28
|
+
|
|
29
|
+
def decide(self, resource_url, requirements): # type: ignore[override]
|
|
30
|
+
self.calls.append((resource_url, dict(requirements)))
|
|
31
|
+
return evaluate_payment(self.facts, requirements, resource_url, GuardPolicy())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
paid: list[str] = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pay_x402(url: str, pay_to: str, amount: str, network: str = "eip155:8453") -> str:
|
|
38
|
+
"""Pay an x402 seller (test stub)."""
|
|
39
|
+
paid.append(pay_to)
|
|
40
|
+
return f"PAID {pay_to}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_weather(city: str) -> str:
|
|
44
|
+
"""Get the weather."""
|
|
45
|
+
return f"Sunny in {city}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture(autouse=True)
|
|
49
|
+
def _reset() -> None:
|
|
50
|
+
paid.clear()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_blocks_unknown_pay_to_and_allows_observed_one() -> None:
|
|
54
|
+
guard = RecordingGuard()
|
|
55
|
+
seen: list[Decision] = []
|
|
56
|
+
guard.on_decision = seen.append
|
|
57
|
+
model = ScriptedModel(
|
|
58
|
+
[
|
|
59
|
+
("bad", "pay_x402", {"url": SELLER, "pay_to": BAD, "amount": "2000"}),
|
|
60
|
+
("good", "pay_x402", {"url": SELLER, "pay_to": GOOD, "amount": "2000"}),
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
agent = Agent(client=model, tools=[pay_x402], middleware=[BotsRatedPaymentMiddleware(["pay_x402"], guard=guard)])
|
|
64
|
+
out = results(await agent.run("pay"))
|
|
65
|
+
assert paid == [GOOD] # the blocked call never ran
|
|
66
|
+
assert "was not executed" in str(out["bad"]) and "payto_mismatch" in str(out["bad"])
|
|
67
|
+
assert out["good"] == f"PAID {GOOD}"
|
|
68
|
+
assert [d.action for d in seen] == ["block", "allow"]
|
|
69
|
+
assert guard.calls[0] == (SELLER, {"scheme": "exact", "network": "eip155:8453", "payTo": BAD, "amount": "2000"})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_other_tools_are_not_checked() -> None:
|
|
73
|
+
guard = RecordingGuard()
|
|
74
|
+
model = ScriptedModel([("w", "get_weather", {"city": "Paris"})])
|
|
75
|
+
agent = Agent(client=model, tools=[get_weather], middleware=[BotsRatedPaymentMiddleware(["pay_x402"], guard=guard)])
|
|
76
|
+
out = results(await agent.run("weather"))
|
|
77
|
+
assert out["w"] == "Sunny in Paris"
|
|
78
|
+
assert guard.calls == []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def test_unreadable_quote_fails_closed() -> None:
|
|
82
|
+
guard = RecordingGuard()
|
|
83
|
+
|
|
84
|
+
def pay_somehow(target: str) -> str:
|
|
85
|
+
"""A payment tool whose arguments carry no quote."""
|
|
86
|
+
paid.append(target)
|
|
87
|
+
return "PAID"
|
|
88
|
+
|
|
89
|
+
model = ScriptedModel([("x", "pay_somehow", {"target": SELLER})])
|
|
90
|
+
agent = Agent(
|
|
91
|
+
client=model, tools=[pay_somehow], middleware=[BotsRatedPaymentMiddleware(["pay_somehow"], guard=guard)]
|
|
92
|
+
)
|
|
93
|
+
out = results(await agent.run("pay"))
|
|
94
|
+
assert paid == [] and guard.calls == []
|
|
95
|
+
assert "quote_unreadable" in str(out["x"])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_custom_extractor_and_metadata() -> None:
|
|
99
|
+
guard = RecordingGuard()
|
|
100
|
+
captured: dict = {}
|
|
101
|
+
|
|
102
|
+
def extract(args): # type: ignore[no-untyped-def]
|
|
103
|
+
return PaymentQuote(url=args["endpoint"], pay_to=args["recipient"], amount=str(args["atomic"]))
|
|
104
|
+
|
|
105
|
+
from agent_framework import FunctionMiddleware
|
|
106
|
+
|
|
107
|
+
class Capture(FunctionMiddleware):
|
|
108
|
+
async def process(self, context, call_next): # type: ignore[no-untyped-def]
|
|
109
|
+
await call_next()
|
|
110
|
+
captured.update(context.metadata)
|
|
111
|
+
|
|
112
|
+
def buy(endpoint: str, recipient: str, atomic: int) -> str:
|
|
113
|
+
"""Buy."""
|
|
114
|
+
paid.append(recipient)
|
|
115
|
+
return "PAID"
|
|
116
|
+
|
|
117
|
+
model = ScriptedModel([("c", "buy", {"endpoint": SELLER, "recipient": GOOD, "atomic": 5})])
|
|
118
|
+
agent = Agent(
|
|
119
|
+
client=model,
|
|
120
|
+
tools=[buy],
|
|
121
|
+
middleware=[Capture(), BotsRatedPaymentMiddleware({"buy": extract}, guard=guard)],
|
|
122
|
+
)
|
|
123
|
+
await agent.run("buy")
|
|
124
|
+
assert paid == [GOOD]
|
|
125
|
+
assert captured[DECISION_METADATA_KEY].action == "allow"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def test_on_block_raise_ends_the_run() -> None:
|
|
129
|
+
guard = RecordingGuard()
|
|
130
|
+
model = ScriptedModel([("bad", "pay_x402", {"url": SELLER, "pay_to": BAD, "amount": "1"})])
|
|
131
|
+
agent = Agent(
|
|
132
|
+
client=model,
|
|
133
|
+
tools=[pay_x402],
|
|
134
|
+
middleware=[BotsRatedPaymentMiddleware(["pay_x402"], guard=guard, on_block="raise")],
|
|
135
|
+
)
|
|
136
|
+
with pytest.raises(BotsRatedPaymentBlocked) as info:
|
|
137
|
+
await agent.run("pay")
|
|
138
|
+
assert info.value.decision.reasons[0].code == "payto_mismatch"
|
|
139
|
+
assert paid == []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def test_unreachable_botsrated_follows_guard_fail_open_setting() -> None:
|
|
143
|
+
class Down(PrePaymentGuard):
|
|
144
|
+
def decide(self, resource_url, requirements): # type: ignore[override]
|
|
145
|
+
eff = "info" if self.fail_open else "block"
|
|
146
|
+
return Decision(
|
|
147
|
+
"allow" if self.fail_open else "block", "seller.example", [Reason("botsrated_unavailable", "down", eff)]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
for fail_open, expected in ((True, [GOOD]), (False, [])):
|
|
151
|
+
paid.clear()
|
|
152
|
+
model = ScriptedModel([("p", "pay_x402", {"url": SELLER, "pay_to": GOOD, "amount": "1"})])
|
|
153
|
+
mw = BotsRatedPaymentMiddleware(["pay_x402"], guard=Down(fail_open=fail_open))
|
|
154
|
+
await Agent(client=model, tools=[pay_x402], middleware=[mw]).run("pay")
|
|
155
|
+
assert paid == expected
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.parametrize(
|
|
159
|
+
"args",
|
|
160
|
+
[
|
|
161
|
+
{"url": SELLER, "pay_to": GOOD},
|
|
162
|
+
{"url": SELLER, "pay_to": GOOD, "amount": "1.5"},
|
|
163
|
+
{"url": SELLER, "pay_to": GOOD, "amount": True},
|
|
164
|
+
{"pay_to": GOOD, "amount": "1"},
|
|
165
|
+
{"url": 5, "pay_to": GOOD, "amount": "1"},
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
def test_default_extractor_rejects_incomplete_quotes(args: dict) -> None:
|
|
169
|
+
with pytest.raises(ValueError):
|
|
170
|
+
default_quote_extractor(args)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_default_extractor_reads_camel_case() -> None:
|
|
174
|
+
q = default_quote_extractor({"url": SELLER, "payTo": GOOD, "amount": 7, "network": "eip155:84532"})
|
|
175
|
+
assert q.requirements() == {"scheme": "exact", "network": "eip155:84532", "payTo": GOOD, "amount": "7"}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_needs_a_payment_tool() -> None:
|
|
179
|
+
with pytest.raises(ValueError):
|
|
180
|
+
BotsRatedPaymentMiddleware([])
|