oneshot-python 0.1.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.
oneshot/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """oneshot-python — Core Python SDK for the OneShot API."""
2
+
3
+ from oneshot._errors import (
4
+ ContentBlockedError,
5
+ JobError,
6
+ JobTimeoutError,
7
+ OneShotError,
8
+ ToolError,
9
+ ValidationError,
10
+ )
11
+ from oneshot.client import OneShotClient
12
+ from oneshot.x402 import sign_payment_authorization
13
+
14
+ __all__ = [
15
+ "OneShotClient",
16
+ "OneShotError",
17
+ "ToolError",
18
+ "JobError",
19
+ "JobTimeoutError",
20
+ "ValidationError",
21
+ "ContentBlockedError",
22
+ "sign_payment_authorization",
23
+ ]
oneshot/_errors.py ADDED
@@ -0,0 +1,48 @@
1
+ """Error classes mirroring the TypeScript OneShot SDK error hierarchy."""
2
+
3
+
4
+ class OneShotError(Exception):
5
+ """Base error for all OneShot operations."""
6
+
7
+
8
+ class ToolError(OneShotError):
9
+ """HTTP-level error from the OneShot API."""
10
+
11
+ def __init__(self, message: str, status_code: int, response_body: str) -> None:
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+ self.response_body = response_body
15
+
16
+
17
+ class JobError(OneShotError):
18
+ """Async job completed with an error."""
19
+
20
+ def __init__(self, message: str, job_id: str, job_error: str) -> None:
21
+ super().__init__(message)
22
+ self.job_id = job_id
23
+ self.job_error = job_error
24
+
25
+
26
+ class JobTimeoutError(OneShotError):
27
+ """Async job exceeded the polling timeout."""
28
+
29
+ def __init__(self, job_id: str, elapsed_ms: int) -> None:
30
+ super().__init__(f"Job {job_id} timed out after {elapsed_ms / 1000}s")
31
+ self.job_id = job_id
32
+ self.elapsed_ms = elapsed_ms
33
+
34
+
35
+ class ValidationError(OneShotError):
36
+ """Client-side input validation failure."""
37
+
38
+ def __init__(self, message: str, field: str) -> None:
39
+ super().__init__(message)
40
+ self.field = field
41
+
42
+
43
+ class ContentBlockedError(OneShotError):
44
+ """Content safety filter rejected the request."""
45
+
46
+ def __init__(self, message: str, categories: list[str]) -> None:
47
+ super().__init__(message)
48
+ self.categories = categories
oneshot/client.py ADDED
@@ -0,0 +1,334 @@
1
+ """OneShotClient — HTTP client with x402 payment flow.
2
+
3
+ Ports the ``OneShot`` class from ``libs/agent-sdk/src/index.ts``.
4
+ Handles: quote -> pay -> poll lifecycle for paid tools, and simple
5
+ GET/POST for free endpoints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import time
13
+ from typing import Any, Optional
14
+
15
+ import httpx
16
+ from eth_account import Account
17
+
18
+ from oneshot._errors import (
19
+ ContentBlockedError,
20
+ JobError,
21
+ JobTimeoutError,
22
+ OneShotError,
23
+ ToolError,
24
+ ValidationError,
25
+ )
26
+ from oneshot.x402 import sign_payment_authorization
27
+
28
+ SDK_VERSION = "0.1.0"
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Environment configuration (mirrors TS SDK)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ TEST_ENV = {
35
+ "base_url": "https://api-stg.oneshotagent.com",
36
+ "rpc_url": "https://sepolia.base.org",
37
+ "chain_id": 84532,
38
+ "usdc_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
39
+ }
40
+
41
+ PROD_ENV = {
42
+ "base_url": "https://win.oneshotagent.com",
43
+ "rpc_url": "https://mainnet.base.org",
44
+ "chain_id": 8453,
45
+ "usdc_address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
46
+ }
47
+
48
+ _POLL_INTERVAL = 2.0 # seconds
49
+ _MAX_POLL_RETRIES = 3
50
+
51
+
52
+ class OneShotClient:
53
+ """Synchronous + async HTTP client for the OneShot API.
54
+
55
+ Supports the full x402 quote-then-pay lifecycle for paid tools and
56
+ simple requests for free endpoints.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ private_key: str,
62
+ *,
63
+ test_mode: bool = True,
64
+ base_url: Optional[str] = None,
65
+ debug: bool = False,
66
+ ) -> None:
67
+ env = TEST_ENV if test_mode else PROD_ENV
68
+ self.base_url = base_url or env["base_url"]
69
+ self.chain_id: int = env["chain_id"]
70
+ self.usdc_address: str = env["usdc_address"]
71
+ self.test_mode = test_mode
72
+ self.debug = debug
73
+
74
+ # Derive wallet address from private key
75
+ self._private_key = private_key
76
+ acct = Account.from_key(private_key)
77
+ self.address: str = acct.address
78
+
79
+ # ------------------------------------------------------------------
80
+ # Headers
81
+ # ------------------------------------------------------------------
82
+
83
+ def _headers(self) -> dict[str, str]:
84
+ return {
85
+ "Content-Type": "application/json",
86
+ "X-Agent-ID": self.address,
87
+ "X-OneShot-SDK-Version": SDK_VERSION,
88
+ }
89
+
90
+ def _log(self, msg: str) -> None:
91
+ if self.debug:
92
+ print(f"[OneShot] {msg}")
93
+
94
+ # ------------------------------------------------------------------
95
+ # Paid tool flow (POST -> 402 -> sign -> POST w/ payment -> poll)
96
+ # ------------------------------------------------------------------
97
+
98
+ def call_tool(
99
+ self,
100
+ endpoint: str,
101
+ payload: dict[str, Any],
102
+ *,
103
+ max_cost: Optional[float] = None,
104
+ timeout_sec: int = 120,
105
+ ) -> Any:
106
+ """Execute a paid tool call (blocking). Handles the full x402 flow."""
107
+ return asyncio.get_event_loop().run_until_complete(
108
+ self.acall_tool(endpoint, payload, max_cost=max_cost, timeout_sec=timeout_sec)
109
+ )
110
+
111
+ async def acall_tool(
112
+ self,
113
+ endpoint: str,
114
+ payload: dict[str, Any],
115
+ *,
116
+ max_cost: Optional[float] = None,
117
+ timeout_sec: int = 120,
118
+ ) -> Any:
119
+ """Execute a paid tool call (async). Handles the full x402 flow."""
120
+ url = f"{self.base_url}{endpoint}"
121
+
122
+ async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
123
+ # Step 1 — Initial POST (expect 402 for paid tools)
124
+ resp = await client.post(url, headers=self._headers(), json=payload)
125
+
126
+ # Handle validation / content-blocked errors
127
+ if resp.status_code == 400:
128
+ data = resp.json()
129
+ err_type = data.get("error", "")
130
+ if err_type == "content_blocked":
131
+ raise ContentBlockedError(
132
+ data.get("message", "Content blocked"),
133
+ data.get("categories", []),
134
+ )
135
+ raise ValidationError(
136
+ data.get("message", "Invalid request"), "request"
137
+ )
138
+
139
+ # If not 402, this might be a free tool that returned directly
140
+ if resp.status_code != 402:
141
+ resp.raise_for_status()
142
+ result = resp.json()
143
+ # Handle async jobs
144
+ if isinstance(result, dict) and result.get("request_id") and result.get("status") in (
145
+ "pending",
146
+ "processing",
147
+ ):
148
+ return await self._poll_job(client, result["request_id"], timeout_sec)
149
+ return result.get("data", result)
150
+
151
+ # Step 2 — Parse 402 response
152
+ quote_data = resp.json()
153
+ payment_request = quote_data["payment_request"]
154
+ context = quote_data.get("context", {})
155
+ quote_id = context.get("quote_id")
156
+
157
+ # Check max_cost
158
+ total = context.get("total") or context.get("pricing", {}).get("total")
159
+ if max_cost is not None and total is not None:
160
+ if float(total) > max_cost:
161
+ raise OneShotError(
162
+ f"Quote ${total} exceeds max_cost ${max_cost}"
163
+ )
164
+
165
+ self._log(f"Payment required: {payment_request['amount']} USDC")
166
+
167
+ # Step 3 — Sign x402 payment
168
+ auth = sign_payment_authorization(
169
+ private_key=self._private_key,
170
+ from_address=self.address,
171
+ to_address=payment_request["recipient"],
172
+ amount=payment_request["amount"],
173
+ token_address=payment_request["token_address"],
174
+ chain_id=payment_request["chain_id"],
175
+ network=f"eip155:{payment_request['chain_id']}",
176
+ )
177
+
178
+ # Step 4 — Re-POST with payment headers
179
+ headers = {
180
+ **self._headers(),
181
+ "x-payment": json.dumps(auth),
182
+ }
183
+ if quote_id:
184
+ headers["x-quote-id"] = quote_id
185
+
186
+ resp2 = await client.post(url, headers=headers, json=payload)
187
+
188
+ if resp2.status_code not in (200, 201, 202):
189
+ raise ToolError(
190
+ "Tool request failed after payment",
191
+ resp2.status_code,
192
+ resp2.text,
193
+ )
194
+
195
+ result = resp2.json()
196
+
197
+ # Step 5 — Poll if async job
198
+ if isinstance(result, dict) and result.get("request_id") and result.get("status") in (
199
+ "pending",
200
+ "processing",
201
+ ):
202
+ self._log(f"Job queued: {result['request_id']}")
203
+ return await self._poll_job(client, result["request_id"], timeout_sec)
204
+
205
+ return result.get("data", result)
206
+
207
+ # ------------------------------------------------------------------
208
+ # Free endpoint helpers
209
+ # ------------------------------------------------------------------
210
+
211
+ def call_free_get(
212
+ self,
213
+ endpoint: str,
214
+ params: Optional[dict[str, str]] = None,
215
+ ) -> Any:
216
+ """GET a free endpoint (blocking)."""
217
+ return asyncio.get_event_loop().run_until_complete(
218
+ self.acall_free_get(endpoint, params)
219
+ )
220
+
221
+ async def acall_free_get(
222
+ self,
223
+ endpoint: str,
224
+ params: Optional[dict[str, str]] = None,
225
+ ) -> Any:
226
+ """GET a free endpoint (async)."""
227
+ url = f"{self.base_url}{endpoint}"
228
+ async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
229
+ resp = await client.get(url, headers=self._headers(), params=params)
230
+ if not resp.is_success:
231
+ raise ToolError(f"GET {endpoint} failed", resp.status_code, resp.text)
232
+ return resp.json()
233
+
234
+ def call_free_post(
235
+ self,
236
+ endpoint: str,
237
+ payload: Optional[dict[str, Any]] = None,
238
+ ) -> Any:
239
+ """POST to a free endpoint (blocking)."""
240
+ return asyncio.get_event_loop().run_until_complete(
241
+ self.acall_free_post(endpoint, payload)
242
+ )
243
+
244
+ async def acall_free_post(
245
+ self,
246
+ endpoint: str,
247
+ payload: Optional[dict[str, Any]] = None,
248
+ ) -> Any:
249
+ """POST to a free endpoint (async)."""
250
+ url = f"{self.base_url}{endpoint}"
251
+ async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
252
+ resp = await client.post(url, headers=self._headers(), json=payload or {})
253
+ if not resp.is_success:
254
+ raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
255
+ return resp.json()
256
+
257
+ def call_free_patch(
258
+ self,
259
+ endpoint: str,
260
+ payload: Optional[dict[str, Any]] = None,
261
+ ) -> Any:
262
+ """PATCH a free endpoint (blocking)."""
263
+ return asyncio.get_event_loop().run_until_complete(
264
+ self.acall_free_patch(endpoint, payload)
265
+ )
266
+
267
+ async def acall_free_patch(
268
+ self,
269
+ endpoint: str,
270
+ payload: Optional[dict[str, Any]] = None,
271
+ ) -> Any:
272
+ """PATCH a free endpoint (async)."""
273
+ url = f"{self.base_url}{endpoint}"
274
+ async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
275
+ resp = await client.patch(url, headers=self._headers(), json=payload or {})
276
+ if not resp.is_success:
277
+ raise ToolError(f"PATCH {endpoint} failed", resp.status_code, resp.text)
278
+ # PATCH may return empty body (204)
279
+ if resp.status_code == 204 or not resp.text:
280
+ return {"success": True}
281
+ return resp.json()
282
+
283
+ # ------------------------------------------------------------------
284
+ # Job polling
285
+ # ------------------------------------------------------------------
286
+
287
+ async def _poll_job(
288
+ self,
289
+ client: httpx.AsyncClient,
290
+ request_id: str,
291
+ timeout_sec: int,
292
+ ) -> Any:
293
+ start = time.monotonic()
294
+ retries = 0
295
+
296
+ while (time.monotonic() - start) < timeout_sec:
297
+ try:
298
+ resp = await client.get(
299
+ f"{self.base_url}/v1/requests/{request_id}",
300
+ headers=self._headers(),
301
+ )
302
+ if not resp.is_success:
303
+ raise ToolError(
304
+ "Failed to check job status",
305
+ resp.status_code,
306
+ resp.text,
307
+ )
308
+
309
+ job = resp.json()
310
+
311
+ if job.get("status") == "completed":
312
+ self._log("Job completed")
313
+ return job.get("result", job)
314
+
315
+ if job.get("status") == "failed":
316
+ raise JobError(
317
+ f"Job failed: {job.get('error', 'Unknown')}",
318
+ request_id,
319
+ str(job.get("error", "Unknown")),
320
+ )
321
+
322
+ retries = 0
323
+ await asyncio.sleep(_POLL_INTERVAL)
324
+
325
+ except (OneShotError, JobError):
326
+ raise
327
+ except Exception:
328
+ retries += 1
329
+ if retries > _MAX_POLL_RETRIES:
330
+ raise
331
+ await asyncio.sleep(_POLL_INTERVAL * (2 ** (retries - 1)))
332
+
333
+ elapsed_ms = int((time.monotonic() - start) * 1000)
334
+ raise JobTimeoutError(request_id, elapsed_ms)
oneshot/x402.py ADDED
@@ -0,0 +1,142 @@
1
+ """EIP-712 payment signing for x402 protocol (USDC TransferWithAuthorization).
2
+
3
+ Ports the signing logic from:
4
+ - libs/agent-sdk/src/index.ts (L1511-1568)
5
+ - apps/api-service/src/services/x402-facilitator.ts (L28-29) for domain names
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from typing import Any, TypedDict
13
+
14
+ from eth_account import Account
15
+ from eth_account.messages import encode_typed_data
16
+
17
+
18
+ class PaymentSignature(TypedDict):
19
+ v: int
20
+ r: str
21
+ s: str
22
+
23
+
24
+ class PaymentAuthorization(TypedDict):
25
+ """Mirrors the TS SDK PaymentAuthorization interface."""
26
+
27
+ from_address: str # 'from' is reserved in Python
28
+ to: str
29
+ value: str
30
+ validAfter: int
31
+ validBefore: int
32
+ nonce: str
33
+ signature: PaymentSignature
34
+ network: str
35
+ token: str
36
+
37
+
38
+ def _get_usdc_domain_name(chain_id: int) -> str:
39
+ """Return the EIP-712 domain name for USDC on the given chain.
40
+
41
+ Base Sepolia (84532) uses "USDC", Base Mainnet (8453) uses "USD Coin".
42
+ Source: apps/api-service/src/services/x402-facilitator.ts:28-29
43
+ """
44
+ return "USDC" if chain_id == 84532 else "USD Coin"
45
+
46
+
47
+ def sign_payment_authorization(
48
+ *,
49
+ private_key: str,
50
+ from_address: str,
51
+ to_address: str,
52
+ amount: str,
53
+ token_address: str,
54
+ chain_id: int,
55
+ network: str,
56
+ ) -> dict[str, Any]:
57
+ """Create an EIP-712 TransferWithAuthorization signature for USDC.
58
+
59
+ Args:
60
+ private_key: Hex-encoded private key (with or without 0x prefix).
61
+ from_address: Sender wallet address.
62
+ to_address: Recipient (merchant) address.
63
+ amount: Amount in USDC human-readable units (e.g. "1.50").
64
+ token_address: USDC contract address.
65
+ chain_id: EVM chain ID (84532 for Base Sepolia, 8453 for Base Mainnet).
66
+ network: CAIP-2 network string (e.g. "eip155:84532").
67
+
68
+ Returns:
69
+ Payment authorization dict ready to be JSON-serialized as x-payment header.
70
+ """
71
+ # Convert human-readable amount to USDC base units (6 decimals)
72
+ value = _parse_usdc_amount(amount)
73
+ now = int(time.time())
74
+ nonce = "0x" + os.urandom(32).hex()
75
+
76
+ domain_data = {
77
+ "name": _get_usdc_domain_name(chain_id),
78
+ "version": "2",
79
+ "chainId": chain_id,
80
+ "verifyingContract": token_address,
81
+ }
82
+
83
+ message_types = {
84
+ "TransferWithAuthorization": [
85
+ {"name": "from", "type": "address"},
86
+ {"name": "to", "type": "address"},
87
+ {"name": "value", "type": "uint256"},
88
+ {"name": "validAfter", "type": "uint256"},
89
+ {"name": "validBefore", "type": "uint256"},
90
+ {"name": "nonce", "type": "bytes32"},
91
+ ],
92
+ }
93
+
94
+ message_data = {
95
+ "from": from_address,
96
+ "to": to_address,
97
+ "value": value,
98
+ "validAfter": now - 300,
99
+ "validBefore": now + 3600,
100
+ "nonce": bytes.fromhex(nonce[2:]),
101
+ }
102
+
103
+ signable = encode_typed_data(
104
+ domain_data=domain_data,
105
+ message_types=message_types,
106
+ message_data=message_data,
107
+ )
108
+ signed = Account.sign_message(signable, private_key=private_key)
109
+
110
+ return {
111
+ "from": from_address,
112
+ "to": to_address,
113
+ "value": str(value),
114
+ "validAfter": now - 300,
115
+ "validBefore": now + 3600,
116
+ "nonce": nonce,
117
+ "signature": {
118
+ "v": signed.v,
119
+ "r": hex(signed.r),
120
+ "s": hex(signed.s),
121
+ },
122
+ "network": network,
123
+ "token": token_address,
124
+ }
125
+
126
+
127
+ def _parse_usdc_amount(amount: str) -> int:
128
+ """Convert a human-readable USDC amount to base units (6 decimals).
129
+
130
+ Examples:
131
+ "1.50" -> 1500000
132
+ "10" -> 10000000
133
+ "0.01" -> 10000
134
+ """
135
+ parts = amount.split(".")
136
+ whole = int(parts[0])
137
+ if len(parts) == 1:
138
+ return whole * 1_000_000
139
+
140
+ # Pad or truncate fractional part to 6 digits
141
+ frac_str = parts[1][:6].ljust(6, "0")
142
+ return whole * 1_000_000 + int(frac_str)
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: oneshot-python
3
+ Version: 0.1.0
4
+ Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: eth-account>=0.13.0
8
+ Requires-Dist: httpx>=0.27.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Description-Content-Type: text/plain
13
+
14
+ Core Python SDK for the OneShot API
@@ -0,0 +1,7 @@
1
+ oneshot/__init__.py,sha256=yDrp5FGVWFLuMZUBzS2exrdizzBAWXZXS1VVoesmUuM,498
2
+ oneshot/_errors.py,sha256=hbDMBck3JU3pFlNvSKMWZ4j1uFsyTQNciFVjAatE5rc,1432
3
+ oneshot/client.py,sha256=6qlXeGbfWEBOrmsftilm3q23mn3fAqXWg0M05fb2zs0,11585
4
+ oneshot/x402.py,sha256=mSrCy_uRJqm5QUbi1YwOSDtg5wfZQPEyhsJsYItpjIk,3947
5
+ oneshot_python-0.1.0.dist-info/METADATA,sha256=JMapJpeQNn3KqsM0qrKh3wR_8xRh6hxYSLKxGrXFnuY,446
6
+ oneshot_python-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
+ oneshot_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any