agent-intent-x402 0.2.0__py3-none-any.whl
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_intent_x402/__init__.py +57 -0
- agent_intent_x402/client.py +348 -0
- agent_intent_x402/errors.py +44 -0
- agent_intent_x402/models.py +201 -0
- agent_intent_x402/wallet.py +218 -0
- agent_intent_x402-0.2.0.dist-info/METADATA +159 -0
- agent_intent_x402-0.2.0.dist-info/RECORD +9 -0
- agent_intent_x402-0.2.0.dist-info/WHEEL +4 -0
- agent_intent_x402-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""agent-intent-x402 — pay-per-request access for AI agents over x402.
|
|
2
|
+
|
|
3
|
+
Declare *what* you want, discover *who* provides it, and pay for the
|
|
4
|
+
request on-chain with a single signature — no accounts, no API keys, no
|
|
5
|
+
platform lock-in. Payment speaks the open `x402 <https://x402.org>`_
|
|
6
|
+
protocol, so the same client works against any compliant gateway.
|
|
7
|
+
|
|
8
|
+
from agent_intent_x402 import AIPClient, IntentType, Wallet
|
|
9
|
+
|
|
10
|
+
# A wallet lets the client answer HTTP 402 challenges automatically.
|
|
11
|
+
wallet = Wallet(private_key="0x...")
|
|
12
|
+
with AIPClient(wallet=wallet) as client:
|
|
13
|
+
result = client.resolve(IntentType.CHAT_COMPLETION)
|
|
14
|
+
print(result.best_match.provider_id)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .client import DEFAULT_ENDPOINT, AIPClient
|
|
18
|
+
from .errors import (
|
|
19
|
+
AIPAPIError,
|
|
20
|
+
AIPAuthError,
|
|
21
|
+
AIPConnectionError,
|
|
22
|
+
AIPError,
|
|
23
|
+
AIPPaymentRequiredError,
|
|
24
|
+
)
|
|
25
|
+
from .models import (
|
|
26
|
+
Constraints,
|
|
27
|
+
IntentType,
|
|
28
|
+
Match,
|
|
29
|
+
OptimizeFor,
|
|
30
|
+
Preferences,
|
|
31
|
+
Pricing,
|
|
32
|
+
Provider,
|
|
33
|
+
ResolveResult,
|
|
34
|
+
)
|
|
35
|
+
from .wallet import Wallet, WalletError
|
|
36
|
+
|
|
37
|
+
__version__ = "0.2.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AIPClient",
|
|
41
|
+
"DEFAULT_ENDPOINT",
|
|
42
|
+
"Wallet",
|
|
43
|
+
"WalletError",
|
|
44
|
+
"AIPError",
|
|
45
|
+
"AIPAPIError",
|
|
46
|
+
"AIPAuthError",
|
|
47
|
+
"AIPConnectionError",
|
|
48
|
+
"AIPPaymentRequiredError",
|
|
49
|
+
"IntentType",
|
|
50
|
+
"OptimizeFor",
|
|
51
|
+
"Constraints",
|
|
52
|
+
"Preferences",
|
|
53
|
+
"Pricing",
|
|
54
|
+
"Match",
|
|
55
|
+
"ResolveResult",
|
|
56
|
+
"Provider",
|
|
57
|
+
]
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Agent Intent client with built-in x402 payment.
|
|
2
|
+
|
|
3
|
+
Declare an intent, discover a provider, and pay for the request on-chain
|
|
4
|
+
over the open `x402 <https://x402.org>`_ protocol. When the client is
|
|
5
|
+
given a :class:`~agent_intent_x402.wallet.Wallet`, any HTTP 402 challenge
|
|
6
|
+
is answered automatically: the wallet signs the payment and the request
|
|
7
|
+
is retried with a ``PAYMENT-SIGNATURE`` header — no accounts, no API keys.
|
|
8
|
+
|
|
9
|
+
The client is vendor-neutral. ``endpoint`` defaults to a hosted gateway
|
|
10
|
+
for convenience but can point at any compliant x402 service.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
from agent_intent_x402 import AIPClient, IntentType, OptimizeFor, Wallet
|
|
15
|
+
|
|
16
|
+
wallet = Wallet(private_key="0x...")
|
|
17
|
+
client = AIPClient(wallet=wallet)
|
|
18
|
+
result = client.resolve(
|
|
19
|
+
IntentType.CHAT_COMPLETION,
|
|
20
|
+
constraints={"max_price_usd": 0.01},
|
|
21
|
+
preferences={"optimize_for": OptimizeFor.COST},
|
|
22
|
+
)
|
|
23
|
+
print(result.best_match.provider_id, result.best_match.model)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from typing import Any, Optional, Union
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from .errors import (
|
|
34
|
+
AIPAPIError,
|
|
35
|
+
AIPAuthError,
|
|
36
|
+
AIPConnectionError,
|
|
37
|
+
AIPPaymentRequiredError,
|
|
38
|
+
)
|
|
39
|
+
from .models import (
|
|
40
|
+
Constraints,
|
|
41
|
+
IntentType,
|
|
42
|
+
Preferences,
|
|
43
|
+
Provider,
|
|
44
|
+
ResolveResult,
|
|
45
|
+
)
|
|
46
|
+
from .wallet import Wallet
|
|
47
|
+
|
|
48
|
+
DEFAULT_ENDPOINT = "https://api.jarvisclaw.ai"
|
|
49
|
+
__version__ = "0.2.0"
|
|
50
|
+
|
|
51
|
+
_ConstraintsArg = Union[Constraints, dict[str, Any], None]
|
|
52
|
+
_PreferencesArg = Union[Preferences, dict[str, Any], None]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _coerce_constraints(value: _ConstraintsArg) -> dict[str, Any]:
|
|
56
|
+
if value is None:
|
|
57
|
+
return {}
|
|
58
|
+
if isinstance(value, Constraints):
|
|
59
|
+
return value.to_dict()
|
|
60
|
+
return dict(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _coerce_preferences(value: _PreferencesArg) -> dict[str, Any]:
|
|
64
|
+
if value is None:
|
|
65
|
+
return {}
|
|
66
|
+
if isinstance(value, Preferences):
|
|
67
|
+
return value.to_dict()
|
|
68
|
+
out = dict(value)
|
|
69
|
+
# normalize enum values so they serialize as plain strings
|
|
70
|
+
if "optimize_for" in out and out["optimize_for"] is not None:
|
|
71
|
+
out["optimize_for"] = str(out["optimize_for"])
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AIPClient:
|
|
76
|
+
"""Synchronous client with built-in x402 payment.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
api_key: Optional bearer token for gateways that still use API-key
|
|
80
|
+
auth. Falls back to the ``JARVISCLAW_API_KEY`` environment
|
|
81
|
+
variable. Not required when paying with a wallet.
|
|
82
|
+
wallet: A :class:`~agent_intent_x402.wallet.Wallet`. When set, HTTP
|
|
83
|
+
402 responses are answered automatically by signing the payment
|
|
84
|
+
and retrying with a ``PAYMENT-SIGNATURE`` header.
|
|
85
|
+
endpoint: Base URL of the gateway. Defaults to a hosted service;
|
|
86
|
+
override to target any compliant x402 deployment.
|
|
87
|
+
timeout: Request timeout in seconds.
|
|
88
|
+
http_client: Optional preconfigured ``httpx.Client`` (for custom
|
|
89
|
+
transports, proxies, or connection pooling).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
api_key: Optional[str] = None,
|
|
95
|
+
*,
|
|
96
|
+
wallet: Optional[Wallet] = None,
|
|
97
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
http_client: Optional[httpx.Client] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.api_key = api_key or os.getenv("JARVISCLAW_API_KEY")
|
|
102
|
+
self.wallet = wallet
|
|
103
|
+
self.endpoint = endpoint.rstrip("/")
|
|
104
|
+
self._owns_client = http_client is None
|
|
105
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
106
|
+
|
|
107
|
+
# ── context manager ────────────────────────────────────────────────
|
|
108
|
+
def __enter__(self) -> "AIPClient":
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *exc: Any) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
"""Close the underlying HTTP client (only if this client owns it)."""
|
|
116
|
+
if self._owns_client:
|
|
117
|
+
self._http.close()
|
|
118
|
+
|
|
119
|
+
# ── internal request helper ────────────────────────────────────────
|
|
120
|
+
def _headers(self) -> dict[str, str]:
|
|
121
|
+
headers = {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
"User-Agent": f"agent-intent-x402/{__version__}",
|
|
124
|
+
}
|
|
125
|
+
if self.api_key:
|
|
126
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
127
|
+
return headers
|
|
128
|
+
|
|
129
|
+
def _request(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
*,
|
|
134
|
+
json: Optional[dict[str, Any]] = None,
|
|
135
|
+
params: Optional[dict[str, Any]] = None,
|
|
136
|
+
) -> Any:
|
|
137
|
+
url = f"{self.endpoint}{path}"
|
|
138
|
+
try:
|
|
139
|
+
resp = self._http.request(
|
|
140
|
+
method, url, json=json, params=params, headers=self._headers()
|
|
141
|
+
)
|
|
142
|
+
except httpx.RequestError as exc:
|
|
143
|
+
raise AIPConnectionError(f"request to {url} failed: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
# x402: answer a payment challenge by signing it and retrying once.
|
|
146
|
+
if resp.status_code == 402 and self.wallet is not None:
|
|
147
|
+
resp = self._pay_and_retry(
|
|
148
|
+
resp, method, url, json=json, params=params
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if resp.status_code >= 400:
|
|
152
|
+
self._raise_for_status(resp)
|
|
153
|
+
|
|
154
|
+
if not resp.content:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
return resp.json()
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
raise AIPAPIError(
|
|
160
|
+
f"invalid JSON in response from {url}",
|
|
161
|
+
status_code=resp.status_code,
|
|
162
|
+
body=resp.text,
|
|
163
|
+
) from exc
|
|
164
|
+
|
|
165
|
+
def _pay_and_retry(
|
|
166
|
+
self,
|
|
167
|
+
resp: httpx.Response,
|
|
168
|
+
method: str,
|
|
169
|
+
url: str,
|
|
170
|
+
*,
|
|
171
|
+
json: Optional[dict[str, Any]],
|
|
172
|
+
params: Optional[dict[str, Any]],
|
|
173
|
+
) -> httpx.Response:
|
|
174
|
+
"""Sign the 402 challenge and retry the request once.
|
|
175
|
+
|
|
176
|
+
Returns the retried response, or the original 402 response if the
|
|
177
|
+
challenge body could not be parsed (so normal error handling runs).
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
challenge = resp.json()
|
|
181
|
+
except ValueError:
|
|
182
|
+
return resp
|
|
183
|
+
|
|
184
|
+
signature = self.wallet.sign_challenge(challenge)
|
|
185
|
+
headers = self._headers()
|
|
186
|
+
headers["PAYMENT-SIGNATURE"] = signature
|
|
187
|
+
try:
|
|
188
|
+
return self._http.request(
|
|
189
|
+
method, url, json=json, params=params, headers=headers
|
|
190
|
+
)
|
|
191
|
+
except httpx.RequestError as exc:
|
|
192
|
+
raise AIPConnectionError(f"request to {url} failed: {exc}") from exc
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
196
|
+
detail: Optional[str] = None
|
|
197
|
+
body: Any = None
|
|
198
|
+
try:
|
|
199
|
+
body = resp.json()
|
|
200
|
+
if isinstance(body, dict):
|
|
201
|
+
detail = body.get("detail") or body.get("error") or body.get("message")
|
|
202
|
+
except ValueError:
|
|
203
|
+
body = resp.text
|
|
204
|
+
detail = resp.text or None
|
|
205
|
+
|
|
206
|
+
message = f"AIP server returned {resp.status_code}"
|
|
207
|
+
if detail:
|
|
208
|
+
message = f"{message}: {detail}"
|
|
209
|
+
|
|
210
|
+
code = resp.status_code
|
|
211
|
+
if code in (401, 403):
|
|
212
|
+
raise AIPAuthError(message, status_code=code, detail=detail, body=body)
|
|
213
|
+
if code == 402:
|
|
214
|
+
raise AIPPaymentRequiredError(
|
|
215
|
+
message, status_code=code, detail=detail, body=body
|
|
216
|
+
)
|
|
217
|
+
raise AIPAPIError(message, status_code=code, detail=detail, body=body)
|
|
218
|
+
|
|
219
|
+
# ── protocol methods ───────────────────────────────────────────────
|
|
220
|
+
def resolve(
|
|
221
|
+
self,
|
|
222
|
+
intent: Union[IntentType, str],
|
|
223
|
+
*,
|
|
224
|
+
constraints: _ConstraintsArg = None,
|
|
225
|
+
preferences: _PreferencesArg = None,
|
|
226
|
+
) -> ResolveResult:
|
|
227
|
+
"""Resolve an intent to ranked provider matches.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
intent: The intent type, e.g. ``IntentType.CHAT_COMPLETION``.
|
|
231
|
+
constraints: Hard requirements a provider must satisfy. Accepts a
|
|
232
|
+
:class:`Constraints` instance or a plain dict, e.g.
|
|
233
|
+
``{"max_price_usd": 0.01, "features": ["function_calling"]}``.
|
|
234
|
+
preferences: Soft ranking directives. Accepts a
|
|
235
|
+
:class:`Preferences` instance or a plain dict, e.g.
|
|
236
|
+
``{"optimize_for": "cost", "limit": 5}``.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
A :class:`ResolveResult`. Use ``.best_match`` for the top provider
|
|
240
|
+
or ``.matches`` for the full ranked list.
|
|
241
|
+
|
|
242
|
+
Endpoint: ``POST /v1/intent/resolve`` (requires authentication).
|
|
243
|
+
"""
|
|
244
|
+
body: dict[str, Any] = {"intent": str(intent)}
|
|
245
|
+
c = _coerce_constraints(constraints)
|
|
246
|
+
if c:
|
|
247
|
+
body["constraints"] = c
|
|
248
|
+
p = _coerce_preferences(preferences)
|
|
249
|
+
if p:
|
|
250
|
+
body["preferences"] = p
|
|
251
|
+
data = self._request("POST", "/v1/intent/resolve", json=body)
|
|
252
|
+
return ResolveResult.from_dict(data or {})
|
|
253
|
+
|
|
254
|
+
def resolve_natural(
|
|
255
|
+
self,
|
|
256
|
+
query: str,
|
|
257
|
+
*,
|
|
258
|
+
session_id: Optional[str] = None,
|
|
259
|
+
constraints: _ConstraintsArg = None,
|
|
260
|
+
) -> dict[str, Any]:
|
|
261
|
+
"""Resolve a natural-language query to providers or a clarification.
|
|
262
|
+
|
|
263
|
+
Unlike :meth:`resolve`, the gateway may respond with a follow-up
|
|
264
|
+
question instead of matches, so the raw response dict is returned.
|
|
265
|
+
|
|
266
|
+
Endpoint: ``POST /v1/intent/resolve/natural`` (requires authentication).
|
|
267
|
+
"""
|
|
268
|
+
body: dict[str, Any] = {"query": query}
|
|
269
|
+
if session_id is not None:
|
|
270
|
+
body["session_id"] = session_id
|
|
271
|
+
c = _coerce_constraints(constraints)
|
|
272
|
+
if c:
|
|
273
|
+
body["constraints"] = c
|
|
274
|
+
return self._request("POST", "/v1/intent/resolve/natural", json=body) or {}
|
|
275
|
+
|
|
276
|
+
def discover(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
intent_type: Union[IntentType, str, None] = None,
|
|
280
|
+
) -> list[Provider]:
|
|
281
|
+
"""Discover providers, optionally filtered by intent type.
|
|
282
|
+
|
|
283
|
+
Endpoint: ``GET /v1/intent/discover`` (no authentication required).
|
|
284
|
+
"""
|
|
285
|
+
params: dict[str, Any] = {}
|
|
286
|
+
if intent_type is not None:
|
|
287
|
+
params["intent_type"] = str(intent_type)
|
|
288
|
+
data = self._request("GET", "/v1/intent/discover", params=params or None)
|
|
289
|
+
return _extract_providers(data)
|
|
290
|
+
|
|
291
|
+
def execute(
|
|
292
|
+
self,
|
|
293
|
+
intent: Union[IntentType, str],
|
|
294
|
+
payload: dict[str, Any],
|
|
295
|
+
*,
|
|
296
|
+
constraints: _ConstraintsArg = None,
|
|
297
|
+
preferences: _PreferencesArg = None,
|
|
298
|
+
) -> Any:
|
|
299
|
+
"""Resolve an intent and execute it against the selected provider.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
intent: The intent type to execute.
|
|
303
|
+
payload: The provider-facing request body (e.g. chat messages).
|
|
304
|
+
constraints: Hard requirements for provider selection.
|
|
305
|
+
preferences: Soft ranking directives for provider selection.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The raw provider response, proxied through the gateway.
|
|
309
|
+
|
|
310
|
+
Endpoint: ``POST /v1/intent/execute`` (requires authentication).
|
|
311
|
+
"""
|
|
312
|
+
body: dict[str, Any] = {"intent": str(intent), "payload": payload}
|
|
313
|
+
c = _coerce_constraints(constraints)
|
|
314
|
+
if c:
|
|
315
|
+
body["constraints"] = c
|
|
316
|
+
p = _coerce_preferences(preferences)
|
|
317
|
+
if p:
|
|
318
|
+
body["preferences"] = p
|
|
319
|
+
return self._request("POST", "/v1/intent/execute", json=body)
|
|
320
|
+
|
|
321
|
+
def list_intent_types(self) -> list[str]:
|
|
322
|
+
"""List the intent types the gateway supports.
|
|
323
|
+
|
|
324
|
+
Endpoint: ``GET /v1/intent/types`` (no authentication required).
|
|
325
|
+
"""
|
|
326
|
+
data = self._request("GET", "/v1/intent/types")
|
|
327
|
+
if isinstance(data, dict):
|
|
328
|
+
return data.get("intent_types", [])
|
|
329
|
+
return data or []
|
|
330
|
+
|
|
331
|
+
def list_providers(self) -> list[Provider]:
|
|
332
|
+
"""List all registered providers.
|
|
333
|
+
|
|
334
|
+
Endpoint: ``GET /v1/providers`` (no authentication required).
|
|
335
|
+
"""
|
|
336
|
+
data = self._request("GET", "/v1/providers")
|
|
337
|
+
return _extract_providers(data)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _extract_providers(data: Any) -> list[Provider]:
|
|
341
|
+
"""Normalize the various provider list shapes into ``list[Provider]``."""
|
|
342
|
+
if data is None:
|
|
343
|
+
return []
|
|
344
|
+
if isinstance(data, dict):
|
|
345
|
+
items = data.get("providers", data.get("matches", []))
|
|
346
|
+
else:
|
|
347
|
+
items = data
|
|
348
|
+
return [Provider.from_dict(item) for item in items if isinstance(item, dict)]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Exceptions raised by the Agent Intent Protocol client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AIPError(Exception):
|
|
9
|
+
"""Base class for all AIP client errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AIPConnectionError(AIPError):
|
|
13
|
+
"""Raised when the request never reached the server (network/timeout)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AIPAPIError(AIPError):
|
|
17
|
+
"""Raised when the server returns a non-2xx response.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
status_code: HTTP status code returned by the server.
|
|
21
|
+
detail: Server-provided error detail, when available.
|
|
22
|
+
body: Raw parsed response body, when available.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
*,
|
|
29
|
+
status_code: int,
|
|
30
|
+
detail: Optional[str] = None,
|
|
31
|
+
body: Optional[Any] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.detail = detail
|
|
36
|
+
self.body = body
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AIPAuthError(AIPAPIError):
|
|
40
|
+
"""Raised on 401/403 — missing or invalid API key."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AIPPaymentRequiredError(AIPAPIError):
|
|
44
|
+
"""Raised on 402 — x402 payment required to complete the request."""
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Data models for the Agent Intent Protocol.
|
|
2
|
+
|
|
3
|
+
All models mirror the wire format served by the AIP gateway
|
|
4
|
+
(default platform: https://api.jarvisclaw.ai). Field names match the
|
|
5
|
+
JSON contract exactly so responses deserialize without translation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IntentType(str, Enum):
|
|
16
|
+
"""Intent types supported by the protocol.
|
|
17
|
+
|
|
18
|
+
An intent describes *what* an agent wants to accomplish, decoupled
|
|
19
|
+
from *which* provider or model fulfils it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
CHAT_COMPLETION = "chat_completion"
|
|
23
|
+
IMAGE_GENERATION = "image_generation"
|
|
24
|
+
VIDEO_GENERATION = "video_generation"
|
|
25
|
+
TEXT_TO_SPEECH = "text_to_speech"
|
|
26
|
+
WEB_SEARCH = "web_search"
|
|
27
|
+
KNOWLEDGE_SEARCH = "knowledge_search"
|
|
28
|
+
PROMPT_OPTIMIZATION = "prompt_optimization"
|
|
29
|
+
DOCUMENT_PROCESSING = "document_processing"
|
|
30
|
+
UTILITY = "utility"
|
|
31
|
+
CODE_EXECUTION = "code_execution"
|
|
32
|
+
DATA_ANALYSIS = "data_analysis"
|
|
33
|
+
TRANSLATION = "translation"
|
|
34
|
+
CODE_GENERATION = "code_generation"
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str: # allow seamless use as a plain string
|
|
37
|
+
return self.value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OptimizeFor(str, Enum):
|
|
41
|
+
"""Soft optimization strategy used when ranking matched providers."""
|
|
42
|
+
|
|
43
|
+
COST = "cost"
|
|
44
|
+
QUALITY = "quality"
|
|
45
|
+
LATENCY = "latency"
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return self.value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Constraints:
|
|
53
|
+
"""Hard requirements a provider MUST satisfy to be considered a match.
|
|
54
|
+
|
|
55
|
+
Any field left as ``None`` (or an empty list) is omitted from the
|
|
56
|
+
request and imposes no constraint.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
max_price_usd: Optional[float] = None
|
|
60
|
+
max_latency_ms: Optional[int] = None
|
|
61
|
+
features: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
out: dict[str, Any] = {}
|
|
65
|
+
if self.max_price_usd is not None:
|
|
66
|
+
out["max_price_usd"] = self.max_price_usd
|
|
67
|
+
if self.max_latency_ms is not None:
|
|
68
|
+
out["max_latency_ms"] = self.max_latency_ms
|
|
69
|
+
if self.features:
|
|
70
|
+
out["features"] = list(self.features)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Preferences:
|
|
76
|
+
"""Soft directives that steer ranking without excluding providers."""
|
|
77
|
+
|
|
78
|
+
optimize_for: Optional[OptimizeFor | str] = None
|
|
79
|
+
limit: Optional[int] = None
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, Any]:
|
|
82
|
+
out: dict[str, Any] = {}
|
|
83
|
+
if self.optimize_for is not None:
|
|
84
|
+
out["optimize_for"] = str(self.optimize_for)
|
|
85
|
+
if self.limit is not None:
|
|
86
|
+
out["limit"] = self.limit
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Pricing:
|
|
92
|
+
"""Price breakdown for a provider offering."""
|
|
93
|
+
|
|
94
|
+
input_per_million: Optional[float] = None
|
|
95
|
+
output_per_million: Optional[float] = None
|
|
96
|
+
per_call: Optional[float] = None
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, data: Optional[dict[str, Any]]) -> Optional["Pricing"]:
|
|
100
|
+
if not data:
|
|
101
|
+
return None
|
|
102
|
+
return cls(
|
|
103
|
+
input_per_million=data.get("input_per_million"),
|
|
104
|
+
output_per_million=data.get("output_per_million"),
|
|
105
|
+
per_call=data.get("per_call"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Match:
|
|
111
|
+
"""A single provider matched to an intent, with score and pricing.
|
|
112
|
+
|
|
113
|
+
Unknown fields returned by the server are preserved in ``raw`` so the
|
|
114
|
+
SDK never silently drops data as the protocol evolves.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
provider_id: str
|
|
118
|
+
score: float = 0.0
|
|
119
|
+
estimated_price_usd: Optional[float] = None
|
|
120
|
+
pricing: Optional[Pricing] = None
|
|
121
|
+
endpoint: Optional[str] = None
|
|
122
|
+
model: Optional[str] = None
|
|
123
|
+
reason: Optional[str] = None
|
|
124
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dict(cls, data: dict[str, Any]) -> "Match":
|
|
128
|
+
return cls(
|
|
129
|
+
provider_id=data.get("provider_id", ""),
|
|
130
|
+
score=data.get("score", 0.0),
|
|
131
|
+
estimated_price_usd=data.get("estimated_price_usd"),
|
|
132
|
+
pricing=Pricing.from_dict(data.get("pricing")),
|
|
133
|
+
endpoint=data.get("endpoint"),
|
|
134
|
+
model=data.get("model"),
|
|
135
|
+
reason=data.get("reason"),
|
|
136
|
+
raw=data,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ResolveResult:
|
|
142
|
+
"""Result of resolving an intent to ranked provider matches.
|
|
143
|
+
|
|
144
|
+
Mirrors the gateway response ``{matches, intent_type, total_available}``.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
matches: list[Match] = field(default_factory=list)
|
|
148
|
+
intent_type: Optional[str] = None
|
|
149
|
+
total_available: Optional[int] = None
|
|
150
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def best_match(self) -> Optional[Match]:
|
|
154
|
+
"""The top-ranked match, or ``None`` when nothing matched.
|
|
155
|
+
|
|
156
|
+
The gateway returns matches pre-sorted by score, so this is simply
|
|
157
|
+
the first element — exposed as a property for ergonomic access.
|
|
158
|
+
"""
|
|
159
|
+
return self.matches[0] if self.matches else None
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_dict(cls, data: dict[str, Any]) -> "ResolveResult":
|
|
163
|
+
return cls(
|
|
164
|
+
matches=[Match.from_dict(m) for m in data.get("matches", [])],
|
|
165
|
+
intent_type=data.get("intent_type"),
|
|
166
|
+
total_available=data.get("total_available"),
|
|
167
|
+
raw=data,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class Provider:
|
|
173
|
+
"""A provider entry from the discovery / listing endpoints."""
|
|
174
|
+
|
|
175
|
+
id: str
|
|
176
|
+
name: Optional[str] = None
|
|
177
|
+
intent_types: list[str] = field(default_factory=list)
|
|
178
|
+
pricing: Optional[Pricing] = None
|
|
179
|
+
features: list[str] = field(default_factory=list)
|
|
180
|
+
endpoint: Optional[str] = None
|
|
181
|
+
source: Optional[str] = None
|
|
182
|
+
resource_id: Optional[str] = None
|
|
183
|
+
server_id: Optional[str] = None
|
|
184
|
+
description: Optional[str] = None
|
|
185
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_dict(cls, data: dict[str, Any]) -> "Provider":
|
|
189
|
+
return cls(
|
|
190
|
+
id=data.get("id", ""),
|
|
191
|
+
name=data.get("name"),
|
|
192
|
+
intent_types=data.get("intent_types", []),
|
|
193
|
+
pricing=Pricing.from_dict(data.get("pricing")),
|
|
194
|
+
features=data.get("features", []),
|
|
195
|
+
endpoint=data.get("endpoint"),
|
|
196
|
+
source=data.get("source"),
|
|
197
|
+
resource_id=data.get("resource_id"),
|
|
198
|
+
server_id=data.get("server_id"),
|
|
199
|
+
description=data.get("description"),
|
|
200
|
+
raw=data,
|
|
201
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""x402 wallet — sign HTTP 402 payment challenges with an EVM private key.
|
|
2
|
+
|
|
3
|
+
This is the core of ``agent-intent-x402``: it lets an agent pay for a
|
|
4
|
+
request over the open `x402 <https://x402.org>`_ protocol without any
|
|
5
|
+
platform-specific glue. Given a 402 challenge, the wallet builds and
|
|
6
|
+
signs an EIP-712 ``TransferWithAuthorization`` (EIP-3009) message and
|
|
7
|
+
returns the ``PAYMENT-SIGNATURE`` header value the server expects.
|
|
8
|
+
|
|
9
|
+
The signing is vendor-neutral: it targets the standard USDC
|
|
10
|
+
``TransferWithAuthorization`` typed-data used across the x402 ecosystem,
|
|
11
|
+
so the same wallet works against any compliant x402 gateway, not just one
|
|
12
|
+
platform.
|
|
13
|
+
|
|
14
|
+
from agent_intent_x402 import Wallet
|
|
15
|
+
|
|
16
|
+
wallet = Wallet(private_key="0x...")
|
|
17
|
+
header = wallet.sign_challenge(challenge) # PAYMENT-SIGNATURE value
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import json
|
|
24
|
+
import secrets
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
# Default asset: USDC on Base (eip155:8453). Used only when a 402 challenge
|
|
29
|
+
# omits the asset address. The EIP-712 domain name/version below match the
|
|
30
|
+
# canonical USDC TransferWithAuthorization contract.
|
|
31
|
+
DEFAULT_NETWORK = "eip155:8453"
|
|
32
|
+
DEFAULT_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
33
|
+
USDC_DOMAIN_NAME = "USD Coin"
|
|
34
|
+
USDC_DOMAIN_VERSION = "2"
|
|
35
|
+
|
|
36
|
+
# The x402 protocol version this wallet speaks.
|
|
37
|
+
X402_VERSION = 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WalletError(Exception):
|
|
41
|
+
"""Raised when a 402 challenge cannot be parsed or signed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _import_eth_account() -> Any:
|
|
45
|
+
try:
|
|
46
|
+
from eth_account import Account # noqa: WPS433 (local import by design)
|
|
47
|
+
except ImportError as exc: # pragma: no cover - exercised via message only
|
|
48
|
+
raise WalletError(
|
|
49
|
+
"x402 payment requires the 'eth-account' package. "
|
|
50
|
+
"Install it with: pip install 'agent-intent-x402[wallet]'"
|
|
51
|
+
) from exc
|
|
52
|
+
return Account
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Wallet:
|
|
56
|
+
"""An EVM wallet that signs x402 payment challenges.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
private_key: Hex private key (with or without the ``0x`` prefix).
|
|
60
|
+
|
|
61
|
+
The wallet never sends funds itself. It produces a signed
|
|
62
|
+
authorization that the gateway settles on-chain, so a single signature
|
|
63
|
+
authorizes exactly the amount named in the challenge and nothing more.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, private_key: str) -> None:
|
|
67
|
+
account_cls = _import_eth_account()
|
|
68
|
+
key = private_key if private_key.startswith("0x") else f"0x{private_key}"
|
|
69
|
+
self._account = account_cls.from_key(key)
|
|
70
|
+
self.address: str = self._account.address
|
|
71
|
+
|
|
72
|
+
# ── public API ─────────────────────────────────────────────────────
|
|
73
|
+
def sign_challenge(self, challenge: Any) -> str:
|
|
74
|
+
"""Sign a 402 challenge and return the ``PAYMENT-SIGNATURE`` value.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
challenge: The parsed JSON body of the 402 response. The first
|
|
78
|
+
entry of ``accepts`` is used as the payment terms.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A base64-encoded x402 v2 payload suitable for the
|
|
82
|
+
``PAYMENT-SIGNATURE`` request header.
|
|
83
|
+
"""
|
|
84
|
+
terms = self._select_terms(challenge)
|
|
85
|
+
return self._sign_terms(terms)
|
|
86
|
+
|
|
87
|
+
# ── internals ──────────────────────────────────────────────────────
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _select_terms(challenge: Any) -> dict[str, Any]:
|
|
90
|
+
if not isinstance(challenge, dict):
|
|
91
|
+
raise WalletError("402 challenge body is not a JSON object")
|
|
92
|
+
accepts = challenge.get("accepts")
|
|
93
|
+
if not isinstance(accepts, list) or not accepts:
|
|
94
|
+
raise WalletError("402 challenge has no 'accepts' payment terms")
|
|
95
|
+
terms = accepts[0]
|
|
96
|
+
if not isinstance(terms, dict):
|
|
97
|
+
raise WalletError("402 payment terms are malformed")
|
|
98
|
+
if not terms.get("payTo"):
|
|
99
|
+
raise WalletError("402 payment terms missing 'payTo'")
|
|
100
|
+
if terms.get("amount") in (None, ""):
|
|
101
|
+
raise WalletError("402 payment terms missing 'amount'")
|
|
102
|
+
return terms
|
|
103
|
+
|
|
104
|
+
def _sign_terms(self, terms: dict[str, Any]) -> str:
|
|
105
|
+
network = terms.get("network") or DEFAULT_NETWORK
|
|
106
|
+
asset = terms.get("asset") or DEFAULT_USDC
|
|
107
|
+
pay_to = terms["payTo"]
|
|
108
|
+
amount = str(terms["amount"])
|
|
109
|
+
max_timeout = int(terms.get("maxTimeoutSeconds", 60))
|
|
110
|
+
extra = terms.get("extra")
|
|
111
|
+
|
|
112
|
+
chain_id = _chain_id_from_network(network)
|
|
113
|
+
now = int(time.time())
|
|
114
|
+
valid_after = 0
|
|
115
|
+
valid_before = now + max_timeout
|
|
116
|
+
nonce = "0x" + secrets.token_hex(32)
|
|
117
|
+
|
|
118
|
+
signature = self._sign_transfer_authorization(
|
|
119
|
+
chain_id=chain_id,
|
|
120
|
+
verifying_contract=asset,
|
|
121
|
+
from_addr=self.address,
|
|
122
|
+
to_addr=pay_to,
|
|
123
|
+
value=amount,
|
|
124
|
+
valid_after=valid_after,
|
|
125
|
+
valid_before=valid_before,
|
|
126
|
+
nonce_hex=nonce,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
payload = {
|
|
130
|
+
"x402Version": X402_VERSION,
|
|
131
|
+
"accepted": {
|
|
132
|
+
"scheme": terms.get("scheme", "exact"),
|
|
133
|
+
"network": network,
|
|
134
|
+
"amount": amount,
|
|
135
|
+
"asset": asset,
|
|
136
|
+
"payTo": pay_to,
|
|
137
|
+
"maxTimeoutSeconds": max_timeout,
|
|
138
|
+
"extra": extra if extra is not None else {},
|
|
139
|
+
},
|
|
140
|
+
"payload": {
|
|
141
|
+
"signature": signature,
|
|
142
|
+
"authorization": {
|
|
143
|
+
"from": self.address,
|
|
144
|
+
"to": pay_to,
|
|
145
|
+
"value": amount,
|
|
146
|
+
"validAfter": str(valid_after),
|
|
147
|
+
"validBefore": str(valid_before),
|
|
148
|
+
"nonce": nonce,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
"extensions": {},
|
|
152
|
+
}
|
|
153
|
+
payload_json = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
154
|
+
return base64.b64encode(payload_json).decode("ascii")
|
|
155
|
+
|
|
156
|
+
def _sign_transfer_authorization(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
chain_id: int,
|
|
160
|
+
verifying_contract: str,
|
|
161
|
+
from_addr: str,
|
|
162
|
+
to_addr: str,
|
|
163
|
+
value: str,
|
|
164
|
+
valid_after: int,
|
|
165
|
+
valid_before: int,
|
|
166
|
+
nonce_hex: str,
|
|
167
|
+
) -> str:
|
|
168
|
+
account_cls = _import_eth_account()
|
|
169
|
+
from eth_account.messages import encode_typed_data
|
|
170
|
+
|
|
171
|
+
full_message = {
|
|
172
|
+
"types": {
|
|
173
|
+
"EIP712Domain": [
|
|
174
|
+
{"name": "name", "type": "string"},
|
|
175
|
+
{"name": "version", "type": "string"},
|
|
176
|
+
{"name": "chainId", "type": "uint256"},
|
|
177
|
+
{"name": "verifyingContract", "type": "address"},
|
|
178
|
+
],
|
|
179
|
+
"TransferWithAuthorization": [
|
|
180
|
+
{"name": "from", "type": "address"},
|
|
181
|
+
{"name": "to", "type": "address"},
|
|
182
|
+
{"name": "value", "type": "uint256"},
|
|
183
|
+
{"name": "validAfter", "type": "uint256"},
|
|
184
|
+
{"name": "validBefore", "type": "uint256"},
|
|
185
|
+
{"name": "nonce", "type": "bytes32"},
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
"primaryType": "TransferWithAuthorization",
|
|
189
|
+
"domain": {
|
|
190
|
+
"name": USDC_DOMAIN_NAME,
|
|
191
|
+
"version": USDC_DOMAIN_VERSION,
|
|
192
|
+
"chainId": chain_id,
|
|
193
|
+
"verifyingContract": verifying_contract,
|
|
194
|
+
},
|
|
195
|
+
"message": {
|
|
196
|
+
"from": from_addr,
|
|
197
|
+
"to": to_addr,
|
|
198
|
+
"value": int(value),
|
|
199
|
+
"validAfter": valid_after,
|
|
200
|
+
"validBefore": valid_before,
|
|
201
|
+
"nonce": bytes.fromhex(nonce_hex[2:]),
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
signable = encode_typed_data(full_message=full_message)
|
|
205
|
+
signed = account_cls.sign_message(signable, private_key=self._account.key)
|
|
206
|
+
# eth-account already yields v in {27, 28}; do not add 27 again.
|
|
207
|
+
return "0x" + signed.signature.hex()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _chain_id_from_network(network: str) -> int:
|
|
211
|
+
"""Extract the numeric chain id from an ``eip155:<id>`` network string."""
|
|
212
|
+
if network.startswith("eip155:"):
|
|
213
|
+
tail = network.split(":", 1)[1]
|
|
214
|
+
try:
|
|
215
|
+
return int(tail)
|
|
216
|
+
except ValueError:
|
|
217
|
+
pass
|
|
218
|
+
return 8453 # default to Base
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-intent-x402
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Pay-per-request access to any service for AI agents, over the open x402 protocol. Declare an intent, discover a provider, and settle on-chain with a single signature.
|
|
5
|
+
Project-URL: Homepage, https://github.com/api-jarvisclaw/agent-intent-x402
|
|
6
|
+
Project-URL: Documentation, https://github.com/api-jarvisclaw/agent-intent-x402#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/api-jarvisclaw/agent-intent-x402
|
|
8
|
+
Project-URL: Issues, https://github.com/api-jarvisclaw/agent-intent-x402/issues
|
|
9
|
+
Project-URL: Protocol, https://github.com/api-jarvisclaw/agent-intent-protocol
|
|
10
|
+
Project-URL: x402, https://x402.org
|
|
11
|
+
Author-email: JarvisClaw <dev@jarvisclaw.ai>
|
|
12
|
+
License: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: agent,ai,eip-3009,http-402,intent,llm,payments,usdc,x402
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: eth-account>=0.11.0
|
|
26
|
+
Requires-Dist: httpx>=0.24.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-httpx>=0.21.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# agent-intent-x402
|
|
34
|
+
|
|
35
|
+
Pay-per-request access to any service, for AI agents — over the open
|
|
36
|
+
[x402](https://x402.org) protocol.
|
|
37
|
+
|
|
38
|
+
Declare *what* you want, discover *who* provides it, and pay for the
|
|
39
|
+
request on-chain with a single signature. No accounts, no API keys, no
|
|
40
|
+
platform lock-in. Your agent carries a wallet; the service quotes a
|
|
41
|
+
price with HTTP `402 Payment Required`; the client signs and settles.
|
|
42
|
+
Because payment speaks the open x402 standard, the same client works
|
|
43
|
+
against any compliant gateway.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install agent-intent-x402
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
Give the client a wallet and it settles `402` challenges automatically —
|
|
52
|
+
sign the payment, retry the request, return the result.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from agent_intent_x402 import AIPClient, Wallet, IntentType
|
|
56
|
+
|
|
57
|
+
wallet = Wallet(private_key="0x...") # your agent's EVM key
|
|
58
|
+
|
|
59
|
+
with AIPClient(wallet=wallet, endpoint="https://api.example.com") as client:
|
|
60
|
+
# Resolve an intent to the best-ranked provider. If the gateway
|
|
61
|
+
# answers 402, the wallet pays and the call transparently retries.
|
|
62
|
+
result = client.resolve(
|
|
63
|
+
IntentType.CHAT_COMPLETION,
|
|
64
|
+
constraints={"max_price_usd": 0.01},
|
|
65
|
+
preferences={"optimize_for": "cost"},
|
|
66
|
+
)
|
|
67
|
+
best = result.best_match
|
|
68
|
+
print(f"{best.provider_id} · {best.model} · score={best.score}")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The private key can also be supplied via the `AIP_WALLET_KEY`
|
|
72
|
+
environment variable, in which case `Wallet()` needs no arguments.
|
|
73
|
+
|
|
74
|
+
## How payment works
|
|
75
|
+
|
|
76
|
+
x402 turns HTTP `402 Payment Required` into a usable payment step:
|
|
77
|
+
|
|
78
|
+
1. The client makes a normal request.
|
|
79
|
+
2. If payment is due, the gateway replies `402` with the accepted terms
|
|
80
|
+
(amount, asset, recipient, network) in the body.
|
|
81
|
+
3. The wallet signs an [EIP-3009](https://eips.ethereum.org/EIPS/eip-3009)
|
|
82
|
+
`TransferWithAuthorization` for those exact terms — an EIP-712 typed
|
|
83
|
+
signature, no gas, no on-chain transaction from your side.
|
|
84
|
+
4. The client retries with a `PAYMENT-SIGNATURE` header. The gateway
|
|
85
|
+
verifies and settles on-chain, then serves the response.
|
|
86
|
+
|
|
87
|
+
Payments default to USDC on Base (`eip155:8453`); the network and asset
|
|
88
|
+
come from the gateway's `402` terms, so the wallet always signs exactly
|
|
89
|
+
what it is asked to pay.
|
|
90
|
+
|
|
91
|
+
## Resolve, then execute
|
|
92
|
+
|
|
93
|
+
`resolve` returns ranked matches so you can inspect price and score
|
|
94
|
+
before committing. `execute` resolves *and* runs the intent in one call,
|
|
95
|
+
proxying the provider response back:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
response = client.execute(
|
|
99
|
+
IntentType.CHAT_COMPLETION,
|
|
100
|
+
payload={"messages": [{"role": "user", "content": "Hello"}]},
|
|
101
|
+
preferences={"optimize_for": "quality"},
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Natural-language intents
|
|
106
|
+
|
|
107
|
+
Let the gateway interpret a free-form request. It may resolve directly
|
|
108
|
+
or reply with a clarifying question for a multi-turn exchange:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
reply = client.resolve_natural("I need to transcribe an audio file cheaply")
|
|
112
|
+
if reply.get("status") == "clarify":
|
|
113
|
+
print(reply["message"]) # follow-up question
|
|
114
|
+
else:
|
|
115
|
+
print(reply["matches"]) # resolved providers
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Discovery
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# Every intent type the gateway understands.
|
|
122
|
+
client.list_intent_types()
|
|
123
|
+
|
|
124
|
+
# Providers available for a given intent.
|
|
125
|
+
client.discover(intent_type=IntentType.IMAGE_GENERATION)
|
|
126
|
+
|
|
127
|
+
# The full provider catalogue.
|
|
128
|
+
client.list_providers()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Intent types
|
|
132
|
+
|
|
133
|
+
`chat_completion`, `image_generation`, `video_generation`,
|
|
134
|
+
`text_to_speech`, `web_search`, `knowledge_search`,
|
|
135
|
+
`prompt_optimization`, `document_processing`, `utility`,
|
|
136
|
+
`code_execution`, `data_analysis`, `translation`, `code_generation`.
|
|
137
|
+
|
|
138
|
+
## Errors
|
|
139
|
+
|
|
140
|
+
All exceptions derive from `AIPError`:
|
|
141
|
+
|
|
142
|
+
- `AIPConnectionError` — the request never reached the gateway.
|
|
143
|
+
- `AIPAuthError` — `401`/`403`, missing or invalid credentials.
|
|
144
|
+
- `AIPPaymentRequiredError` — `402`, payment required and no wallet was
|
|
145
|
+
configured (or the payment was rejected).
|
|
146
|
+
- `AIPAPIError` — any other non-2xx response (`status_code`, `detail`, `body`).
|
|
147
|
+
|
|
148
|
+
`WalletError` is raised when a `402` challenge cannot be signed (missing
|
|
149
|
+
key, malformed terms).
|
|
150
|
+
|
|
151
|
+
## Protocol
|
|
152
|
+
|
|
153
|
+
The intent wire format and endpoint contract are defined in the
|
|
154
|
+
[Agent Intent Protocol specification](https://github.com/api-jarvisclaw/agent-intent-protocol).
|
|
155
|
+
The payment layer follows the open [x402](https://x402.org) protocol.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
agent_intent_x402/__init__.py,sha256=0p4eNMcxiJm_XsbjM_Or7XAVp1cn4SnvQqysPvRIYKE,1413
|
|
2
|
+
agent_intent_x402/client.py,sha256=Pg0YmXpEEV58jkrNMrIgIuZz1Bx1C5xIIgKxTPq3bXE,12326
|
|
3
|
+
agent_intent_x402/errors.py,sha256=UaENemhpsjNBwEKCmWdRbnRWEFfEvymk1Us7Wr49eCY,1142
|
|
4
|
+
agent_intent_x402/models.py,sha256=4frbg8HCLBuLIQolYibLu0C94SG42wqNUswxRh6DRP4,6290
|
|
5
|
+
agent_intent_x402/wallet.py,sha256=wRDHXyIntmidJfTUpgs9Fzz7gPrT6PnbEW3pCruc3-s,8286
|
|
6
|
+
agent_intent_x402-0.2.0.dist-info/METADATA,sha256=7AmWTwaNxURxR4gaLm1lT6QJuVhP1JpaZEHogiRtz3s,5811
|
|
7
|
+
agent_intent_x402-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
agent_intent_x402-0.2.0.dist-info/licenses/LICENSE,sha256=9GbKF5fGyJEKXfs105z_AatKU7Rvox0xpwxppmuJGuM,1067
|
|
9
|
+
agent_intent_x402-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JarvisClaw
|
|
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.
|