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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.