oneshot-python 0.8.1__tar.gz → 0.8.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oneshot-python
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -23,9 +23,14 @@ from oneshot._errors import (
23
23
  ToolError,
24
24
  ValidationError,
25
25
  )
26
- from oneshot.x402 import sign_payment_authorization, encode_payment_header, parse_payment_required
26
+ from oneshot.x402 import (
27
+ build_zero_cost_authorization,
28
+ encode_payment_header,
29
+ parse_payment_required,
30
+ sign_payment_authorization,
31
+ )
27
32
 
28
- SDK_VERSION = "0.8.1"
33
+ SDK_VERSION = "0.8.3"
29
34
 
30
35
  # ---------------------------------------------------------------------------
31
36
  # Environment configuration
@@ -157,11 +162,21 @@ class OneShotClient:
157
162
 
158
163
  self._log(f"Payment required: {payment_request['amount']} USDC")
159
164
 
160
- # Step 3 — Sign x402 payment (skip if credits cover full cost)
165
+ # Step 3 — Sign x402 payment (zero-cost auth if credits cover full cost)
161
166
  amount = float(payment_request["amount"])
162
167
  if amount == 0:
163
- self._log("Credits cover full cost — skipping payment signature")
164
- headers = {**self._headers()}
168
+ self._log("Credits cover full cost — sending zero-cost authorization")
169
+ auth = build_zero_cost_authorization(
170
+ from_address=self.address,
171
+ to_address=payment_request["recipient"],
172
+ accepted=accepted,
173
+ resource=parsed_req.get("resource"),
174
+ extensions=parsed_req.get("extensions"),
175
+ )
176
+ headers = {
177
+ **self._headers(),
178
+ "payment-signature": encode_payment_header(auth),
179
+ }
165
180
  if quote_id:
166
181
  headers["x-quote-id"] = quote_id
167
182
  resp2 = await client.post(url, headers=headers, json=payload)
@@ -389,6 +404,182 @@ class OneShotClient:
389
404
  """Delete a browser profile. Async."""
390
405
  return await self.acall_free_delete(f"/v1/tools/browser/profiles/{profile_id}")
391
406
 
407
+ # ------------------------------------------------------------------
408
+ # Paid tool convenience methods
409
+ # ------------------------------------------------------------------
410
+
411
+ def research(self, topic: str, *, depth: str = "deep", **kwargs: Any) -> Any:
412
+ """Run deep web research on a topic. Blocking."""
413
+ return self.call_tool("/v1/tools/research", {"topic": topic, "depth": depth, **kwargs})
414
+
415
+ async def aresearch(self, topic: str, *, depth: str = "deep", **kwargs: Any) -> Any:
416
+ """Run deep web research on a topic. Async."""
417
+ return await self.acall_tool("/v1/tools/research", {"topic": topic, "depth": depth, **kwargs})
418
+
419
+ def web_search(self, query: str, *, max_results: int = 5, **kwargs: Any) -> Any:
420
+ """Search the web. Blocking."""
421
+ return self.call_tool("/v1/tools/search", {"query": query, "max_results": max_results, **kwargs})
422
+
423
+ async def aweb_search(self, query: str, *, max_results: int = 5, **kwargs: Any) -> Any:
424
+ """Search the web. Async."""
425
+ return await self.acall_tool("/v1/tools/search", {"query": query, "max_results": max_results, **kwargs})
426
+
427
+ def web_read(self, url: str, **kwargs: Any) -> Any:
428
+ """Read a web page and extract content as markdown. Blocking."""
429
+ return self.call_tool("/v1/tools/web-read", {"url": url, **kwargs})
430
+
431
+ async def aweb_read(self, url: str, **kwargs: Any) -> Any:
432
+ """Read a web page and extract content as markdown. Async."""
433
+ return await self.acall_tool("/v1/tools/web-read", {"url": url, **kwargs})
434
+
435
+ def email(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
436
+ """Send an email. Blocking."""
437
+ payload: dict[str, Any] = {"to": to, "subject": subject, "body": body, **kwargs}
438
+ if from_domain:
439
+ payload["from_domain"] = from_domain
440
+ return self.call_tool("/v1/tools/email/send", payload)
441
+
442
+ async def aemail(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
443
+ """Send an email. Async."""
444
+ payload: dict[str, Any] = {"to": to, "subject": subject, "body": body, **kwargs}
445
+ if from_domain:
446
+ payload["from_domain"] = from_domain
447
+ return await self.acall_tool("/v1/tools/email/send", payload)
448
+
449
+ def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
450
+ """Make an AI voice call. Blocking."""
451
+ return self.call_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
452
+
453
+ async def avoice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
454
+ """Make an AI voice call. Async."""
455
+ return await self.acall_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
456
+
457
+ def sms(self, message: str, to_number: str, **kwargs: Any) -> Any:
458
+ """Send an SMS message. Blocking."""
459
+ return self.call_tool("/v1/tools/sms/send", {"message": message, "to_number": to_number, **kwargs})
460
+
461
+ async def asms(self, message: str, to_number: str, **kwargs: Any) -> Any:
462
+ """Send an SMS message. Async."""
463
+ return await self.acall_tool("/v1/tools/sms/send", {"message": message, "to_number": to_number, **kwargs})
464
+
465
+ def people_search(self, **kwargs: Any) -> Any:
466
+ """Search for people by job title, company, etc. Blocking."""
467
+ return self.call_tool("/v1/tools/research/people", kwargs)
468
+
469
+ async def apeople_search(self, **kwargs: Any) -> Any:
470
+ """Search for people by job title, company, etc. Async."""
471
+ return await self.acall_tool("/v1/tools/research/people", kwargs)
472
+
473
+ def enrich_profile(self, **kwargs: Any) -> Any:
474
+ """Enrich a person's profile from LinkedIn URL, email, or name. Blocking."""
475
+ return self.call_tool("/v1/tools/enrich/profile", kwargs)
476
+
477
+ async def aenrich_profile(self, **kwargs: Any) -> Any:
478
+ """Enrich a person's profile from LinkedIn URL, email, or name. Async."""
479
+ return await self.acall_tool("/v1/tools/enrich/profile", kwargs)
480
+
481
+ def find_email(self, company_domain: str, *, full_name: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, **kwargs: Any) -> Any:
482
+ """Find a person's email address. Blocking."""
483
+ payload: dict[str, Any] = {"company_domain": company_domain, **kwargs}
484
+ if full_name:
485
+ payload["full_name"] = full_name
486
+ if first_name:
487
+ payload["first_name"] = first_name
488
+ if last_name:
489
+ payload["last_name"] = last_name
490
+ return self.call_tool("/v1/tools/enrich/email", payload)
491
+
492
+ async def afind_email(self, company_domain: str, *, full_name: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, **kwargs: Any) -> Any:
493
+ """Find a person's email address. Async."""
494
+ payload: dict[str, Any] = {"company_domain": company_domain, **kwargs}
495
+ if full_name:
496
+ payload["full_name"] = full_name
497
+ if first_name:
498
+ payload["first_name"] = first_name
499
+ if last_name:
500
+ payload["last_name"] = last_name
501
+ return await self.acall_tool("/v1/tools/enrich/email", payload)
502
+
503
+ def verify_email(self, email: str) -> Any:
504
+ """Verify email deliverability. Blocking."""
505
+ return self.call_tool("/v1/tools/verify/email", {"email": email})
506
+
507
+ async def averify_email(self, email: str) -> Any:
508
+ """Verify email deliverability. Async."""
509
+ return await self.acall_tool("/v1/tools/verify/email", {"email": email})
510
+
511
+ def deep_research_person(self, **kwargs: Any) -> Any:
512
+ """Deep research on a person. Blocking."""
513
+ return self.call_tool("/v1/tools/research/person", kwargs)
514
+
515
+ async def adeep_research_person(self, **kwargs: Any) -> Any:
516
+ """Deep research on a person. Async."""
517
+ return await self.acall_tool("/v1/tools/research/person", kwargs)
518
+
519
+ def social_profiles(self, **kwargs: Any) -> Any:
520
+ """Discover social media profiles. Blocking."""
521
+ return self.call_tool("/v1/tools/research/social", kwargs)
522
+
523
+ async def asocial_profiles(self, **kwargs: Any) -> Any:
524
+ """Discover social media profiles. Async."""
525
+ return await self.acall_tool("/v1/tools/research/social", kwargs)
526
+
527
+ def article_search(self, name: str, company: str, **kwargs: Any) -> Any:
528
+ """Find articles by or about a person. Blocking."""
529
+ return self.call_tool("/v1/tools/research/articles", {"name": name, "company": company, **kwargs})
530
+
531
+ async def aarticle_search(self, name: str, company: str, **kwargs: Any) -> Any:
532
+ """Find articles by or about a person. Async."""
533
+ return await self.acall_tool("/v1/tools/research/articles", {"name": name, "company": company, **kwargs})
534
+
535
+ def person_newsfeed(self, social_media_url: str, **kwargs: Any) -> Any:
536
+ """Get recent social posts from a person. Blocking."""
537
+ return self.call_tool("/v1/tools/research/newsfeed", {"social_media_url": social_media_url, **kwargs})
538
+
539
+ async def aperson_newsfeed(self, social_media_url: str, **kwargs: Any) -> Any:
540
+ """Get recent social posts from a person. Async."""
541
+ return await self.acall_tool("/v1/tools/research/newsfeed", {"social_media_url": social_media_url, **kwargs})
542
+
543
+ def person_interests(self, **kwargs: Any) -> Any:
544
+ """Analyze a person's interests from their digital footprint. Blocking."""
545
+ return self.call_tool("/v1/tools/research/interests", kwargs)
546
+
547
+ async def aperson_interests(self, **kwargs: Any) -> Any:
548
+ """Analyze a person's interests from their digital footprint. Async."""
549
+ return await self.acall_tool("/v1/tools/research/interests", kwargs)
550
+
551
+ def person_interactions(self, social_media_url: str, **kwargs: Any) -> Any:
552
+ """Get a person's social interactions. Blocking."""
553
+ return self.call_tool("/v1/tools/research/interactions", {"social_media_url": social_media_url, **kwargs})
554
+
555
+ async def aperson_interactions(self, social_media_url: str, **kwargs: Any) -> Any:
556
+ """Get a person's social interactions. Async."""
557
+ return await self.acall_tool("/v1/tools/research/interactions", {"social_media_url": social_media_url, **kwargs})
558
+
559
+ def commerce_search(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
560
+ """Search for products. Blocking."""
561
+ return self.call_tool("/v1/tools/commerce/search", {"query": query, "limit": limit, **kwargs})
562
+
563
+ async def acommerce_search(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
564
+ """Search for products. Async."""
565
+ return await self.acall_tool("/v1/tools/commerce/search", {"query": query, "limit": limit, **kwargs})
566
+
567
+ def commerce_buy(self, product_url: str, shipping_address: dict[str, Any], **kwargs: Any) -> Any:
568
+ """Purchase a product. Blocking."""
569
+ return self.call_tool("/v1/tools/commerce/buy", {"product_url": product_url, "shipping_address": shipping_address, **kwargs})
570
+
571
+ async def acommerce_buy(self, product_url: str, shipping_address: dict[str, Any], **kwargs: Any) -> Any:
572
+ """Purchase a product. Async."""
573
+ return await self.acall_tool("/v1/tools/commerce/buy", {"product_url": product_url, "shipping_address": shipping_address, **kwargs})
574
+
575
+ def build(self, product: dict[str, Any], **kwargs: Any) -> Any:
576
+ """Build a website. Blocking."""
577
+ return self.call_tool("/v1/tools/build", {"product": product, **kwargs})
578
+
579
+ async def abuild(self, product: dict[str, Any], **kwargs: Any) -> Any:
580
+ """Build a website. Async."""
581
+ return await self.acall_tool("/v1/tools/build", {"product": product, **kwargs})
582
+
392
583
  # ------------------------------------------------------------------
393
584
  # Balance
394
585
  # ------------------------------------------------------------------
@@ -192,6 +192,42 @@ def sign_payment_authorization(
192
192
  return result
193
193
 
194
194
 
195
+ def build_zero_cost_authorization(
196
+ *,
197
+ from_address: str,
198
+ to_address: str,
199
+ accepted: PaymentRequirements | dict[str, Any] | None = None,
200
+ resource: dict[str, Any] | None = None,
201
+ extensions: dict[str, Any] | None = None,
202
+ ) -> dict[str, Any]:
203
+ """Build a zero-cost x402 PaymentPayload for credit-covered requests.
204
+
205
+ Instead of skipping the payment header entirely (which causes the server's
206
+ x402 SDK to return a 402 error), we send a stub authorization with value=0
207
+ and signature=0x so the server can recognise the zero-cost intent.
208
+ """
209
+ result: dict[str, Any] = {
210
+ "x402Version": 2,
211
+ "accepted": dict(accepted) if accepted else {},
212
+ "payload": {
213
+ "signature": "0x",
214
+ "authorization": {
215
+ "from": from_address,
216
+ "to": to_address,
217
+ "value": "0",
218
+ "validAfter": "0",
219
+ "validBefore": "0",
220
+ "nonce": "0x" + "00" * 32,
221
+ },
222
+ },
223
+ }
224
+ if resource:
225
+ result["resource"] = resource
226
+ if extensions:
227
+ result["extensions"] = extensions
228
+ return result
229
+
230
+
195
231
  def encode_payment_header(auth: dict[str, Any]) -> str:
196
232
  """Base64-encode a PaymentPayload dict for the payment-signature header."""
197
233
  return base64.b64encode(json.dumps(auth).encode()).decode()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.8.1"
3
+ version = "0.8.3"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -15,6 +15,7 @@ from eth_account import Account
15
15
 
16
16
  from oneshot.x402 import (
17
17
  _parse_usdc_amount,
18
+ build_zero_cost_authorization,
18
19
  encode_payment_header,
19
20
  parse_payment_required,
20
21
  sign_payment_authorization,
@@ -158,6 +159,101 @@ class TestSignPaymentAuthorization:
158
159
  assert auth1["payload"]["authorization"]["nonce"] != auth2["payload"]["authorization"]["nonce"]
159
160
 
160
161
 
162
+ class TestBuildZeroCostAuthorization:
163
+ def test_returns_valid_payload(self) -> None:
164
+ auth = build_zero_cost_authorization(
165
+ from_address=TEST_ADDRESS,
166
+ to_address=TO_ADDRESS,
167
+ accepted=MOCK_ACCEPTED,
168
+ )
169
+ assert auth["x402Version"] == 2
170
+ assert auth["payload"]["signature"] == "0x"
171
+ assert auth["payload"]["authorization"]["value"] == "0"
172
+
173
+ def test_authorization_addresses(self) -> None:
174
+ auth = build_zero_cost_authorization(
175
+ from_address=TEST_ADDRESS,
176
+ to_address=TO_ADDRESS,
177
+ accepted=MOCK_ACCEPTED,
178
+ )
179
+ assert auth["payload"]["authorization"]["from"] == TEST_ADDRESS
180
+ assert auth["payload"]["authorization"]["to"] == TO_ADDRESS
181
+
182
+ def test_nonce_is_32_zero_bytes(self) -> None:
183
+ auth = build_zero_cost_authorization(
184
+ from_address=TEST_ADDRESS,
185
+ to_address=TO_ADDRESS,
186
+ )
187
+ nonce = auth["payload"]["authorization"]["nonce"]
188
+ assert nonce == "0x" + "00" * 32
189
+ assert len(nonce) == 66
190
+
191
+ def test_includes_resource_when_provided(self) -> None:
192
+ resource = {"url": "https://api.example.com/v1/tools/research", "mimeType": "application/json"}
193
+ auth = build_zero_cost_authorization(
194
+ from_address=TEST_ADDRESS,
195
+ to_address=TO_ADDRESS,
196
+ resource=resource,
197
+ )
198
+ assert auth["resource"] == resource
199
+
200
+ def test_omits_resource_when_not_provided(self) -> None:
201
+ auth = build_zero_cost_authorization(
202
+ from_address=TEST_ADDRESS,
203
+ to_address=TO_ADDRESS,
204
+ )
205
+ assert "resource" not in auth
206
+
207
+ def test_includes_extensions_when_provided(self) -> None:
208
+ extensions = {"discovery": {"bodyType": "json", "input": {"topic": "AI"}}}
209
+ auth = build_zero_cost_authorization(
210
+ from_address=TEST_ADDRESS,
211
+ to_address=TO_ADDRESS,
212
+ extensions=extensions,
213
+ )
214
+ assert auth["extensions"] == extensions
215
+
216
+ def test_omits_extensions_when_not_provided(self) -> None:
217
+ auth = build_zero_cost_authorization(
218
+ from_address=TEST_ADDRESS,
219
+ to_address=TO_ADDRESS,
220
+ )
221
+ assert "extensions" not in auth
222
+
223
+ def test_echoes_accepted_requirements(self) -> None:
224
+ auth = build_zero_cost_authorization(
225
+ from_address=TEST_ADDRESS,
226
+ to_address=TO_ADDRESS,
227
+ accepted=MOCK_ACCEPTED,
228
+ )
229
+ assert auth["accepted"]["scheme"] == "exact"
230
+ assert auth["accepted"]["payTo"] == TO_ADDRESS
231
+
232
+ def test_base64_roundtrip(self) -> None:
233
+ """Encoded zero-cost auth should be decodable by the server."""
234
+ auth = build_zero_cost_authorization(
235
+ from_address=TEST_ADDRESS,
236
+ to_address=TO_ADDRESS,
237
+ accepted=MOCK_ACCEPTED,
238
+ )
239
+ encoded = encode_payment_header(auth)
240
+ decoded = json.loads(base64.b64decode(encoded))
241
+ assert decoded["payload"]["authorization"]["value"] == "0"
242
+ assert decoded["payload"]["signature"] == "0x"
243
+
244
+ def test_server_detects_zero_value(self) -> None:
245
+ """Server step 5c: checks decoded.payload.authorization.value === '0'."""
246
+ auth = build_zero_cost_authorization(
247
+ from_address=TEST_ADDRESS,
248
+ to_address=TO_ADDRESS,
249
+ accepted=MOCK_ACCEPTED,
250
+ )
251
+ encoded = encode_payment_header(auth)
252
+ decoded = json.loads(base64.b64decode(encoded))
253
+ # This is the exact check the server does in step 5c
254
+ assert decoded.get("payload", {}).get("authorization", {}).get("value") == "0"
255
+
256
+
161
257
  class TestEncodePaymentHeader:
162
258
  def test_base64_roundtrip(self) -> None:
163
259
  auth = sign_payment_authorization(
File without changes
File without changes