openai-agents-agentsearch 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.
- openai_agents_agentsearch-0.1.0/.gitignore +6 -0
- openai_agents_agentsearch-0.1.0/LICENSE +21 -0
- openai_agents_agentsearch-0.1.0/PKG-INFO +76 -0
- openai_agents_agentsearch-0.1.0/README.md +57 -0
- openai_agents_agentsearch-0.1.0/openai_agents_agentsearch/__init__.py +13 -0
- openai_agents_agentsearch-0.1.0/openai_agents_agentsearch/_client.py +264 -0
- openai_agents_agentsearch-0.1.0/openai_agents_agentsearch/_ops.py +32 -0
- openai_agents_agentsearch-0.1.0/openai_agents_agentsearch/py.typed +0 -0
- openai_agents_agentsearch-0.1.0/openai_agents_agentsearch/tools.py +47 -0
- openai_agents_agentsearch-0.1.0/pyproject.toml +28 -0
- openai_agents_agentsearch-0.1.0/tests/test_tools.py +52 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentSearch / Angel Mendez
|
|
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,76 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openai-agents-agentsearch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenAI Agents SDK function tools for AgentSearch: free live web search, plus pay-per-call extract and JS render
|
|
5
|
+
Project-URL: Homepage, https://agentsearchhq.com/?utm_source=openai-agents-agentsearch&utm_medium=package
|
|
6
|
+
Project-URL: Documentation, https://agentsearchhq.com/agents.md?utm_source=openai-agents-agentsearch&utm_medium=package
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,agentsearch,openai,openai-agents,tools,web-search,x402
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.25
|
|
12
|
+
Requires-Dist: openai-agents>=0.2
|
|
13
|
+
Requires-Dist: pydantic>=2
|
|
14
|
+
Provides-Extra: paid
|
|
15
|
+
Requires-Dist: x402[evm]>=2.0; extra == 'paid'
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# openai-agents-agentsearch
|
|
21
|
+
|
|
22
|
+
Function tools for the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) backed by [AgentSearch](https://agentsearchhq.com/?utm_source=openai-agents-agentsearch&utm_medium=package): live web search for agents. Free search needs no API key.
|
|
23
|
+
|
|
24
|
+
| Tool | Cost |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `agentsearch_search` | **Free** (25 searches/day per user, up to 5 results, no key). Paid and unlimited if a wallet is set. |
|
|
27
|
+
| `agentsearch_extract` | $0.005/call (USDC on Base via x402) |
|
|
28
|
+
| `agentsearch_render` | $0.005/call (headless browser for JS pages) |
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install -U openai-agents-agentsearch
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from agents import Agent, Runner
|
|
40
|
+
from openai_agents_agentsearch import agentsearch_search, agentsearch_tools
|
|
41
|
+
|
|
42
|
+
agent = Agent(
|
|
43
|
+
name="Researcher",
|
|
44
|
+
instructions="Answer with current facts. Cite URLs.",
|
|
45
|
+
tools=[agentsearch_search], # or agentsearch_tools() for search + extract + render
|
|
46
|
+
)
|
|
47
|
+
result = Runner.run_sync(agent, "What changed in the latest OpenAI Agents SDK release?")
|
|
48
|
+
print(result.final_output)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Paid mode
|
|
52
|
+
|
|
53
|
+
Install the `paid` extra (`pip install "openai-agents-agentsearch[paid]"`) and set `AGENTSEARCH_WALLET_PRIVATE_KEY` to the private key of a **new wallet made only for this**, funded with a little USDC on Base (e.g. $1; no ETH needed). Each call costs $0.005 and is paid over x402 through the Pocket Network Agentic Portal using the official x402 Python SDK. With a key set, search also goes paid and unlimited.
|
|
54
|
+
|
|
55
|
+
| Env var | Default |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `AGENTSEARCH_MAX_TOTAL_ATOMIC` | `1000000` ($1.00 per process) |
|
|
58
|
+
| `AGENTSEARCH_MAX_PER_CALL_ATOMIC` | `5000` ($0.005; can only be lowered) |
|
|
59
|
+
| `AGENTSEARCH_TIMEOUT_MS` | `30000` |
|
|
60
|
+
|
|
61
|
+
Limits are checked before anything is signed. The key is never logged or returned. Paid results include a `payment` object.
|
|
62
|
+
|
|
63
|
+
## Errors
|
|
64
|
+
|
|
65
|
+
Tools return `"Error: ..."` strings instead of raising. A 429 from the free relay means the daily limit (25 searches/day per user) was hit, and the message explains how to upgrade.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e ".[test]" && pytest # unit tests (mocked)
|
|
71
|
+
AGENTSEARCH_LIVE=1 pytest -k live # one real free search
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Docs: https://agentsearchhq.com/agents.md?utm_source=openai-agents-agentsearch&utm_medium=package
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# openai-agents-agentsearch
|
|
2
|
+
|
|
3
|
+
Function tools for the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) backed by [AgentSearch](https://agentsearchhq.com/?utm_source=openai-agents-agentsearch&utm_medium=package): live web search for agents. Free search needs no API key.
|
|
4
|
+
|
|
5
|
+
| Tool | Cost |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `agentsearch_search` | **Free** (25 searches/day per user, up to 5 results, no key). Paid and unlimited if a wallet is set. |
|
|
8
|
+
| `agentsearch_extract` | $0.005/call (USDC on Base via x402) |
|
|
9
|
+
| `agentsearch_render` | $0.005/call (headless browser for JS pages) |
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -U openai-agents-agentsearch
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from agents import Agent, Runner
|
|
21
|
+
from openai_agents_agentsearch import agentsearch_search, agentsearch_tools
|
|
22
|
+
|
|
23
|
+
agent = Agent(
|
|
24
|
+
name="Researcher",
|
|
25
|
+
instructions="Answer with current facts. Cite URLs.",
|
|
26
|
+
tools=[agentsearch_search], # or agentsearch_tools() for search + extract + render
|
|
27
|
+
)
|
|
28
|
+
result = Runner.run_sync(agent, "What changed in the latest OpenAI Agents SDK release?")
|
|
29
|
+
print(result.final_output)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Paid mode
|
|
33
|
+
|
|
34
|
+
Install the `paid` extra (`pip install "openai-agents-agentsearch[paid]"`) and set `AGENTSEARCH_WALLET_PRIVATE_KEY` to the private key of a **new wallet made only for this**, funded with a little USDC on Base (e.g. $1; no ETH needed). Each call costs $0.005 and is paid over x402 through the Pocket Network Agentic Portal using the official x402 Python SDK. With a key set, search also goes paid and unlimited.
|
|
35
|
+
|
|
36
|
+
| Env var | Default |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `AGENTSEARCH_MAX_TOTAL_ATOMIC` | `1000000` ($1.00 per process) |
|
|
39
|
+
| `AGENTSEARCH_MAX_PER_CALL_ATOMIC` | `5000` ($0.005; can only be lowered) |
|
|
40
|
+
| `AGENTSEARCH_TIMEOUT_MS` | `30000` |
|
|
41
|
+
|
|
42
|
+
Limits are checked before anything is signed. The key is never logged or returned. Paid results include a `payment` object.
|
|
43
|
+
|
|
44
|
+
## Errors
|
|
45
|
+
|
|
46
|
+
Tools return `"Error: ..."` strings instead of raising. A 429 from the free relay means the daily limit (25 searches/day per user) was hit, and the message explains how to upgrade.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e ".[test]" && pytest # unit tests (mocked)
|
|
52
|
+
AGENTSEARCH_LIVE=1 pytest -k live # one real free search
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Docs: https://agentsearchhq.com/agents.md?utm_source=openai-agents-agentsearch&utm_medium=package
|
|
56
|
+
|
|
57
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""OpenAI Agents SDK function tools for AgentSearch."""
|
|
2
|
+
from importlib import metadata
|
|
3
|
+
|
|
4
|
+
from ._client import AgentSearchError, FREE_LIMIT_MESSAGE, reset_session_budget, session_spent_atomic
|
|
5
|
+
from .tools import agentsearch_extract, agentsearch_render, agentsearch_search, agentsearch_tools
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = metadata.version("openai-agents-agentsearch")
|
|
9
|
+
except metadata.PackageNotFoundError: # pragma: no cover
|
|
10
|
+
__version__ = ""
|
|
11
|
+
|
|
12
|
+
__all__ = ["agentsearch_search", "agentsearch_extract", "agentsearch_render", "agentsearch_tools", "AgentSearchError",
|
|
13
|
+
"FREE_LIMIT_MESSAGE", "reset_session_budget", "session_spent_atomic", "__version__"]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""HTTP layer: free relay search and x402 pay-per-call via the Pocket Network Agentic Portal.
|
|
2
|
+
|
|
3
|
+
The wallet key is read from env, used only to sign, and never logged or returned.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
FREE_RELAY_URL = "https://relay.agentsearchhq.com"
|
|
16
|
+
FREE_RELAY_DAILY_LIMIT = 25
|
|
17
|
+
DEFAULT_PORTAL_URL = "https://agent.pocket.network"
|
|
18
|
+
BASE_NETWORK = "eip155:8453"
|
|
19
|
+
USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
20
|
+
PORTAL_PAYEE = "0xF732ea490c5766071785a2310523f7fA2CEbB829"
|
|
21
|
+
MAX_PER_CALL_ATOMIC = 5000 # $0.005 hard ceiling
|
|
22
|
+
DEFAULT_SESSION_CAP_ATOMIC = 1_000_000 # $1.00
|
|
23
|
+
KEY_ENV_VARS = ("AGENTSEARCH_WALLET_PRIVATE_KEY", "POCKET_PRIVATE_KEY")
|
|
24
|
+
SERVICES = {
|
|
25
|
+
"search": ("agentsearch-web-search-v1", "/v1/search"),
|
|
26
|
+
"extract": ("agentsearch-web-extract-v1", "/v1/extract"),
|
|
27
|
+
"render": ("agentsearch-web-render-v1", "/v1/render"),
|
|
28
|
+
}
|
|
29
|
+
DOCS = "https://agentsearchhq.com/?utm_source=openai-agents-agentsearch&utm_medium=package"
|
|
30
|
+
|
|
31
|
+
FREE_LIMIT_MESSAGE = (
|
|
32
|
+
f"Free search limit reached ({FREE_RELAY_DAILY_LIMIT} searches per day per user; resets daily). "
|
|
33
|
+
"For unlimited search plus extract/render, set AGENTSEARCH_WALLET_PRIVATE_KEY to the key of a NEW wallet "
|
|
34
|
+
"funded with a little USDC on Base ($0.005 per call via x402, capped by AGENTSEARCH_MAX_TOTAL_ATOMIC, "
|
|
35
|
+
f"default $1.00) and install openai-agents-agentsearch[paid]. Docs: {DOCS}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def paid_only_message(name: str) -> str:
|
|
40
|
+
return (
|
|
41
|
+
f"{name} is paid ($0.005 per call in USDC on Base via x402). No wallet is configured, so no call was made. "
|
|
42
|
+
"Set AGENTSEARCH_WALLET_PRIVATE_KEY to the key of a NEW wallet funded with a little USDC on Base and "
|
|
43
|
+
f"install openai-agents-agentsearch[paid]. Docs: {DOCS}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentSearchError(Exception):
|
|
48
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.status = status
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def timeout_s() -> float:
|
|
54
|
+
try:
|
|
55
|
+
n = int(os.environ.get("AGENTSEARCH_TIMEOUT_MS", ""))
|
|
56
|
+
if 1000 <= n <= 300_000:
|
|
57
|
+
return n / 1000
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
return 30.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strip_provider(d: Any) -> Any:
|
|
64
|
+
if isinstance(d, dict):
|
|
65
|
+
d.pop("provider", None)
|
|
66
|
+
return d
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def free_search(query: str, max_results: Optional[int] = None, client: Optional[httpx.Client] = None) -> Any:
|
|
70
|
+
body: dict[str, Any] = {"query": query}
|
|
71
|
+
if max_results is not None:
|
|
72
|
+
body["max_results"] = max_results
|
|
73
|
+
c = client or httpx.Client(timeout=timeout_s())
|
|
74
|
+
try:
|
|
75
|
+
r = c.post(f"{FREE_RELAY_URL}/v1/search", json=body, headers={"accept": "application/json"})
|
|
76
|
+
finally:
|
|
77
|
+
if client is None:
|
|
78
|
+
c.close()
|
|
79
|
+
if r.status_code == 429:
|
|
80
|
+
raise AgentSearchError(FREE_LIMIT_MESSAGE, 429)
|
|
81
|
+
if r.status_code >= 400:
|
|
82
|
+
raise AgentSearchError(f"AgentSearch relay HTTP {r.status_code}: {r.text[:500]}", r.status_code)
|
|
83
|
+
try:
|
|
84
|
+
return _strip_provider(r.json())
|
|
85
|
+
except ValueError:
|
|
86
|
+
return {"raw": r.text}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------- paid mode ----------
|
|
90
|
+
|
|
91
|
+
def has_wallet_key() -> bool:
|
|
92
|
+
return any((os.environ.get(n) or "").strip() for n in KEY_ENV_VARS)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _read_wallet_key() -> Optional[str]:
|
|
96
|
+
for name in KEY_ENV_VARS:
|
|
97
|
+
raw = (os.environ.get(name) or "").strip()
|
|
98
|
+
if not raw:
|
|
99
|
+
continue
|
|
100
|
+
if not re.fullmatch(r"(0x)?[0-9a-fA-F]{64}", raw):
|
|
101
|
+
raise AgentSearchError(
|
|
102
|
+
f"{name} is set but is not a private key (expected 64 hex characters, optional 0x prefix). "
|
|
103
|
+
"The value is not shown. Nothing was signed."
|
|
104
|
+
)
|
|
105
|
+
return raw if raw.startswith("0x") else "0x" + raw
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _atomic_env(names: tuple[str, ...], fallback: int) -> int:
|
|
110
|
+
for name in names:
|
|
111
|
+
raw = (os.environ.get(name) or "").strip()
|
|
112
|
+
if not raw:
|
|
113
|
+
continue
|
|
114
|
+
if not raw.isdigit():
|
|
115
|
+
raise AgentSearchError(f"{name} must be a whole number of USDC atomic units (1000000 = $1.00). Nothing was signed.")
|
|
116
|
+
return int(raw)
|
|
117
|
+
return fallback
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def usd(atomic: int) -> str:
|
|
121
|
+
return f"${atomic // 1_000_000}.{atomic % 1_000_000:06d}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Budget:
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self.spent = 0
|
|
127
|
+
self.lock = threading.Lock()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_budget = _Budget()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def reset_session_budget() -> None:
|
|
134
|
+
global _budget
|
|
135
|
+
_budget = _Budget()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def session_spent_atomic() -> int:
|
|
139
|
+
return _budget.spent
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _eq(a: Any, b: str) -> bool:
|
|
143
|
+
return isinstance(a, str) and a.lower() == b.lower()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def select_accept(accepts: list[Any], ceiling: int) -> Any:
|
|
147
|
+
why = []
|
|
148
|
+
for a in accepts or []:
|
|
149
|
+
if a.scheme != "exact":
|
|
150
|
+
why.append(f"{a.scheme} scheme"); continue
|
|
151
|
+
if a.network != BASE_NETWORK:
|
|
152
|
+
why.append(f"network {a.network}"); continue
|
|
153
|
+
if not _eq(a.asset, USDC_BASE):
|
|
154
|
+
why.append(f"asset {a.asset} is not USDC on Base"); continue
|
|
155
|
+
if not _eq(a.pay_to, PORTAL_PAYEE):
|
|
156
|
+
why.append(f"payee {a.pay_to} is not the portal's"); continue
|
|
157
|
+
if not str(a.amount).isdigit() or int(a.amount) <= 0:
|
|
158
|
+
why.append(f"amount {a.amount}"); continue
|
|
159
|
+
if int(a.amount) > ceiling:
|
|
160
|
+
why.append(f"amount {a.amount} exceeds the cap of {ceiling} atomic units"); continue
|
|
161
|
+
return a
|
|
162
|
+
raise AgentSearchError(f"No acceptable payment option ({'; '.join(why) or 'none offered'}). Nothing was signed.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def call_portal(op: str, body: dict[str, Any], client: Optional[httpx.Client] = None) -> dict[str, Any]:
|
|
166
|
+
"""Unpaid POST -> 402 -> limits checked -> sign (x402 SDK) -> retry once with PAYMENT-SIGNATURE."""
|
|
167
|
+
try:
|
|
168
|
+
from eth_account import Account
|
|
169
|
+
from x402 import x402ClientSync
|
|
170
|
+
from x402.http import x402HTTPClientSync
|
|
171
|
+
from x402.mechanisms.evm.exact import register_exact_evm_client
|
|
172
|
+
except ImportError as e: # pragma: no cover
|
|
173
|
+
raise AgentSearchError(
|
|
174
|
+
"Paid calls need the x402 SDK: pip install 'openai-agents-agentsearch[paid]'. Nothing was signed."
|
|
175
|
+
) from e
|
|
176
|
+
|
|
177
|
+
key = _read_wallet_key()
|
|
178
|
+
if not key:
|
|
179
|
+
raise AgentSearchError(paid_only_message(f"agentsearch_{op}"))
|
|
180
|
+
portal = (os.environ.get("AGENTSEARCH_PORTAL_URL") or DEFAULT_PORTAL_URL).strip().rstrip("/")
|
|
181
|
+
if not portal.startswith("https://") and not re.match(r"http://(localhost|127\.)", portal):
|
|
182
|
+
raise AgentSearchError("AGENTSEARCH_PORTAL_URL must be https://. Nothing was signed.")
|
|
183
|
+
session_cap = _atomic_env(("AGENTSEARCH_MAX_TOTAL_ATOMIC", "POCKET_MAX_TOTAL_ATOMIC"), DEFAULT_SESSION_CAP_ATOMIC)
|
|
184
|
+
per_call = min(_atomic_env(("AGENTSEARCH_MAX_PER_CALL_ATOMIC", "POCKET_MAX_PER_CALL_ATOMIC"), MAX_PER_CALL_ATOMIC), MAX_PER_CALL_ATOMIC)
|
|
185
|
+
service_id, path = SERVICES[op]
|
|
186
|
+
url = f"{portal}/v1/{service_id}{path}"
|
|
187
|
+
|
|
188
|
+
c = client or httpx.Client(timeout=timeout_s())
|
|
189
|
+
try:
|
|
190
|
+
unpaid = c.post(url, json=body)
|
|
191
|
+
if unpaid.status_code != 402:
|
|
192
|
+
raise AgentSearchError(f"Expected 402 from the portal, got {unpaid.status_code}: {unpaid.text[:300]}. Nothing was signed.", unpaid.status_code)
|
|
193
|
+
http = x402HTTPClientSync(x402ClientSync())
|
|
194
|
+
try:
|
|
195
|
+
required = http.get_payment_required_response(lambda n: unpaid.headers.get(n), unpaid.content)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
raise AgentSearchError(f"The portal's 402 is not valid x402 v2 ({e}). Nothing was signed.")
|
|
198
|
+
if getattr(required, "x402_version", None) != 2:
|
|
199
|
+
raise AgentSearchError("Unsupported x402 version. Nothing was signed.")
|
|
200
|
+
res_url = (getattr(getattr(required, "resource", None), "url", "") or "").rstrip("/")
|
|
201
|
+
if not res_url.endswith(f"/v1/{service_id}"):
|
|
202
|
+
raise AgentSearchError(f"The 402 is for resource {res_url}, not {service_id}. Nothing was signed.")
|
|
203
|
+
|
|
204
|
+
with _budget.lock:
|
|
205
|
+
remaining = max(session_cap - _budget.spent, 0)
|
|
206
|
+
ceiling = min(per_call, remaining)
|
|
207
|
+
if ceiling <= 0:
|
|
208
|
+
raise AgentSearchError(
|
|
209
|
+
f"Session spend cap reached: {usd(_budget.spent)} of {usd(session_cap)} signed. "
|
|
210
|
+
"Raise AGENTSEARCH_MAX_TOTAL_ATOMIC to spend more. Nothing was signed."
|
|
211
|
+
)
|
|
212
|
+
chosen = select_accept(list(required.accepts), ceiling)
|
|
213
|
+
amount = int(chosen.amount)
|
|
214
|
+
|
|
215
|
+
def selector(_v: int, options: list[Any]) -> Any:
|
|
216
|
+
for o in options:
|
|
217
|
+
if o.scheme == chosen.scheme and o.network == chosen.network and _eq(o.asset, chosen.asset) and _eq(o.pay_to, chosen.pay_to) and str(o.amount) == str(chosen.amount):
|
|
218
|
+
return o
|
|
219
|
+
raise AgentSearchError("Selected option disappeared. Nothing was signed.")
|
|
220
|
+
|
|
221
|
+
account = Account.from_key(key)
|
|
222
|
+
signer_client = register_exact_evm_client(x402ClientSync(selector), account, networks=BASE_NETWORK)
|
|
223
|
+
payer = x402HTTPClientSync(signer_client)
|
|
224
|
+
signed = payer.create_payment_payload(required)
|
|
225
|
+
auth = (signed.payload or {}).get("authorization") or {}
|
|
226
|
+
if not _eq(auth.get("to"), PORTAL_PAYEE) or int(auth.get("value", 0)) != amount:
|
|
227
|
+
raise AgentSearchError("The signed authorization does not match the approved terms; it was discarded and not sent.")
|
|
228
|
+
_budget.spent += amount # counted at signing
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
paid = c.post(url, json=body, headers=payer.encode_payment_signature_header(signed))
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise AgentSearchError(f"A payment of {usd(amount)} was signed but the paid request failed ({e}). Check the wallet before retrying.")
|
|
234
|
+
if not 200 <= paid.status_code < 300:
|
|
235
|
+
raise AgentSearchError(
|
|
236
|
+
f"The portal answered {paid.status_code}: {paid.text[:300]}. Non-2xx answers are not settled; "
|
|
237
|
+
f"the signed {usd(amount)} still counts against this session's cap.", paid.status_code)
|
|
238
|
+
receipt = None
|
|
239
|
+
try:
|
|
240
|
+
receipt = payer.get_payment_settle_response(lambda n: paid.headers.get(n))
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
tx = getattr(receipt, "transaction", None) if receipt else None
|
|
244
|
+
payment = {
|
|
245
|
+
"amountAtomic": str(amount), "amountUsd": usd(amount), "asset": "USDC", "network": BASE_NETWORK,
|
|
246
|
+
"payTo": PORTAL_PAYEE, "payer": account.address, "serviceId": service_id,
|
|
247
|
+
"settled": bool(getattr(receipt, "success", False)), "transaction": tx,
|
|
248
|
+
}
|
|
249
|
+
if tx:
|
|
250
|
+
payment["explorer"] = f"https://basescan.org/tx/{tx}"
|
|
251
|
+
try:
|
|
252
|
+
env = paid.json()
|
|
253
|
+
except ValueError:
|
|
254
|
+
env = {"raw": paid.text}
|
|
255
|
+
data = env.get("data", env) if isinstance(env, dict) else env
|
|
256
|
+
out = _strip_provider(data) if isinstance(data, dict) else {"data": data}
|
|
257
|
+
return {**out, "payment": payment}
|
|
258
|
+
finally:
|
|
259
|
+
if client is None:
|
|
260
|
+
c.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def dumps(v: Any) -> str:
|
|
264
|
+
return json.dumps(v, ensure_ascii=False)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Framework-neutral operations. Each returns a JSON string, or an "Error: ..." string (never raises)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from . import _client as c
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def search(query: str, max_results: Optional[int] = None, mode: str = "auto") -> str:
|
|
10
|
+
try:
|
|
11
|
+
if not query or not str(query).strip():
|
|
12
|
+
raise c.AgentSearchError("query must not be empty")
|
|
13
|
+
if max_results is not None:
|
|
14
|
+
max_results = max(1, min(int(max_results), 5))
|
|
15
|
+
if mode == "paid" or (mode == "auto" and c.has_wallet_key()):
|
|
16
|
+
body: dict[str, Any] = {"query": query}
|
|
17
|
+
if max_results:
|
|
18
|
+
body["max_results"] = max_results
|
|
19
|
+
return c.dumps(c.call_portal("search", body))
|
|
20
|
+
return c.dumps(c.free_search(query, max_results))
|
|
21
|
+
except Exception as e:
|
|
22
|
+
return f"Error: {e}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def paid(op: str, **kwargs: Any) -> str:
|
|
26
|
+
try:
|
|
27
|
+
if not c.has_wallet_key():
|
|
28
|
+
raise c.AgentSearchError(c.paid_only_message(f"agentsearch_{op}"))
|
|
29
|
+
body = {k: v for k, v in kwargs.items() if v is not None}
|
|
30
|
+
return c.dumps(c.call_portal(op, body))
|
|
31
|
+
except Exception as e:
|
|
32
|
+
return f"Error: {e}"
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Literal, Optional
|
|
4
|
+
|
|
5
|
+
from agents import FunctionTool, function_tool
|
|
6
|
+
|
|
7
|
+
from . import _ops
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@function_tool
|
|
11
|
+
def agentsearch_search(query: str, max_results: Optional[int] = None) -> str:
|
|
12
|
+
"""Live web search. Returns JSON with up to 5 ranked results (title, url, content). Free by default.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
query: the search query.
|
|
16
|
+
max_results: 1-5, default 5.
|
|
17
|
+
"""
|
|
18
|
+
return _ops.search(query, max_results)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@function_tool
|
|
22
|
+
def agentsearch_extract(url: str, max_chars: Optional[int] = None) -> str:
|
|
23
|
+
"""Fetch a URL and return its main content as clean markdown. Paid: $0.005 per call (needs a wallet).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
url: http(s) URL to extract.
|
|
27
|
+
max_chars: optional cap on returned characters.
|
|
28
|
+
"""
|
|
29
|
+
return _ops.paid("extract", url=url, formats=["markdown"], max_chars=max_chars)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@function_tool
|
|
33
|
+
def agentsearch_render(url: str, max_chars: Optional[int] = None,
|
|
34
|
+
wait_until: Optional[Literal["domcontentloaded", "load", "networkidle"]] = None) -> str:
|
|
35
|
+
"""Render a JavaScript-heavy page in a headless browser and return markdown. Paid: $0.005 per call (needs a wallet).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
url: http(s) URL to render.
|
|
39
|
+
max_chars: optional cap on returned characters.
|
|
40
|
+
wait_until: page load event to wait for.
|
|
41
|
+
"""
|
|
42
|
+
return _ops.paid("render", url=url, formats=["markdown"], max_chars=max_chars, wait_until=wait_until)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def agentsearch_tools(include_paid: bool = True) -> List[FunctionTool]:
|
|
46
|
+
"""All AgentSearch tools. Pass include_paid=False for free search only."""
|
|
47
|
+
return [agentsearch_search, agentsearch_extract, agentsearch_render] if include_paid else [agentsearch_search]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openai-agents-agentsearch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OpenAI Agents SDK function tools for AgentSearch: free live web search, plus pay-per-call extract and JS render"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["openai-agents>=0.2", "httpx>=0.25", "pydantic>=2"]
|
|
13
|
+
keywords = ["openai-agents", "openai", "agentsearch", "web-search", "tools", "agents", "x402"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
paid = ["x402[evm]>=2.0"]
|
|
17
|
+
test = ["pytest>=7"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://agentsearchhq.com/?utm_source=openai-agents-agentsearch&utm_medium=package"
|
|
21
|
+
Documentation = "https://agentsearchhq.com/agents.md?utm_source=openai-agents-agentsearch&utm_medium=package"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["openai_agents_agentsearch"]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.sdist]
|
|
27
|
+
include = ["openai_agents_agentsearch", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
28
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mock(monkeypatch, mod, status, body):
|
|
9
|
+
real = httpx.Client
|
|
10
|
+
seen = []
|
|
11
|
+
|
|
12
|
+
def handler(r):
|
|
13
|
+
seen.append(r)
|
|
14
|
+
return httpx.Response(status, json=body)
|
|
15
|
+
monkeypatch.setattr(mod.httpx, "Client", lambda **kw: real(transport=httpx.MockTransport(handler)))
|
|
16
|
+
for n in ("AGENTSEARCH_WALLET_PRIVATE_KEY", "POCKET_PRIVATE_KEY"):
|
|
17
|
+
monkeypatch.delenv(n, raising=False)
|
|
18
|
+
return seen
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
|
|
22
|
+
from openai_agents_agentsearch import agentsearch_search, agentsearch_tools, _client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def call(tool, args):
|
|
26
|
+
from agents.tool_context import ToolContext
|
|
27
|
+
a = json.dumps(args)
|
|
28
|
+
ctx = ToolContext(context=None, tool_name=tool.name, tool_call_id="1", tool_arguments=a)
|
|
29
|
+
return asyncio.run(tool.on_invoke_tool(ctx, a))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_tools_list():
|
|
33
|
+
assert [t.name for t in agentsearch_tools()] == ["agentsearch_search", "agentsearch_extract", "agentsearch_render"]
|
|
34
|
+
assert "query" in agentsearch_search.params_json_schema["properties"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_free_search(monkeypatch):
|
|
38
|
+
mock(monkeypatch, _client, 200, {"results": [{"title": "t", "url": "https://a", "content": "c"}]})
|
|
39
|
+
out = json.loads(call(agentsearch_search, {"query": "hi", "max_results": 1}))
|
|
40
|
+
assert out["results"][0]["url"] == "https://a"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_paid_without_wallet(monkeypatch):
|
|
44
|
+
seen = mock(monkeypatch, _client, 200, {})
|
|
45
|
+
assert "no call was made" in call(agentsearch_tools()[1], {"url": "https://example.com", "max_chars": None}) and not seen
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.skipif(not os.environ.get("AGENTSEARCH_LIVE"), reason="set AGENTSEARCH_LIVE=1")
|
|
49
|
+
def test_live_free_search():
|
|
50
|
+
from openai_agents_agentsearch import _ops
|
|
51
|
+
out = json.loads(_ops.search("OpenAI Agents SDK function tools", 2, "free"))
|
|
52
|
+
assert out["results"][0]["url"].startswith("http")
|