oneshot-python 0.23.1__tar.gz → 0.25.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/.gitignore +1 -0
  2. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/PKG-INFO +2 -2
  3. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/README.md +10 -1
  4. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/oneshot/_errors.py +3 -1
  5. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/oneshot/client.py +298 -34
  6. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/pyproject.toml +2 -2
  7. oneshot_python-0.25.0/tests/test_browser.py +57 -0
  8. oneshot_python-0.25.0/tests/test_email_payload.py +282 -0
  9. oneshot_python-0.25.0/tests/test_linkedin.py +72 -0
  10. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/uv.lock +1 -1
  11. oneshot_python-0.23.1/tests/test_email_payload.py +0 -138
  12. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/oneshot/__init__.py +0 -0
  13. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/oneshot/_types.py +0 -0
  14. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/oneshot/physical_mail.py +0 -0
  15. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/oneshot/x402.py +0 -0
  16. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/__init__.py +0 -0
  17. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_balance.py +0 -0
  18. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_budgets.py +0 -0
  19. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_charge_amount.py +0 -0
  20. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_compute.py +0 -0
  21. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_domains.py +0 -0
  22. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_emergency_error.py +0 -0
  23. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_max_cost_header.py +0 -0
  24. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_payment_rejection.py +0 -0
  25. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_phones_pending.py +0 -0
  26. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_physical_mail.py +0 -0
  27. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_poll_backoff.py +0 -0
  28. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_request_id.py +0 -0
  29. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_tag_receipt_value.py +0 -0
  30. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_wait_false.py +0 -0
  31. {oneshot_python-0.23.1 → oneshot_python-0.25.0}/tests/test_x402.py +0 -0
@@ -1,5 +1,6 @@
1
1
  # Dependencies
2
2
  node_modules
3
+ packages/mcp-server/node_modules/
3
4
  .pnp
4
5
  .pnp.js
5
6
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.23.1
4
- Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools
3
+ Version: 0.25.0
4
+ Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: eth-account>=0.13.0
@@ -192,7 +192,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
192
192
  ## Links
193
193
 
194
194
  - [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
195
- - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 34 tools as LangChain BaseTool
195
+ - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 43 tools as LangChain BaseTool
196
196
  - [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
197
197
  - [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
198
198
  - [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
@@ -205,3 +205,12 @@ MIT
205
205
  ### Physical mail
206
206
 
207
207
  `client.physical_mail` supports U.S. letters and 4×6 postcards through `upload_artwork`, `validate_address`, `preview`, `get_quote`, `approve`, `send`, `get_order`, `recover`, and `cancel`. Async variants prefix the method with `a`. Review each proof and price, then call `approve(..., approved=True)` explicitly. Persist `idempotency_key` before sending. `recover(key)` retrieves the original order without creating another charge. Postal delivery does not prove readership; paid receipts and fulfillment events remain separate. Lob credentials stay on OneShot's server.
208
+
209
+
210
+ ### Account login and 2FA (0.24.0+)
211
+
212
+ Use `create_browser_profile(name)`, then `start_browser_profile_setup(profile_id, login_url)`. Poll `get_browser_profile_setup(profile_id)` until `status` is `idle`, and show the private `live_url` to the user. The live browser currently uses the Browser-Use domain. The user enters their credentials and completes 2FA there; after they finish, call `finish_browser_profile_setup(profile_id)` to save the session. Async variants use an `a` prefix.
213
+
214
+ Finish inspects cookie metadata in a fresh browser before navigation, without returning values. Later `browser(profile_id=..., task=...)` calls reuse the saved profile; verify site authentication with a read before relying on it. Setup expires after 15 minutes, with abandoned-session cleanup by the lifecycle sweep; the profile is retained for reconnection. Never log private live URLs or put credentials/codes in task prompts.
215
+
216
+ Alternatively, `create_browser_profile(name, cookies=[...])` or `storage_state={...}` imports cookies and localStorage. The legacy browser `secrets` field is rejected, and `max_steps` must be an integer from 25 through 100. Failed `JobError`s expose `code` and `partial_result`. Browser `cost` is provider cost; `billed_cost` is the reconciled customer charge, or null while pending.
@@ -44,10 +44,12 @@ class PaymentError(OneShotError):
44
44
  class JobError(OneShotError):
45
45
  """Async job completed with an error."""
46
46
 
47
- def __init__(self, message: str, job_id: str, job_error: str) -> None:
47
+ def __init__(self, message: str, job_id: str, job_error: str, code: str | None = None, partial_result: dict | None = None) -> None:
48
48
  super().__init__(message)
49
49
  self.job_id = job_id
50
50
  self.job_error = job_error
51
+ self.code = code
52
+ self.partial_result = partial_result
51
53
 
52
54
 
53
55
  class JobTimeoutError(OneShotError):
@@ -51,7 +51,7 @@ try:
51
51
 
52
52
  SDK_VERSION = _pkg_version("oneshot-python")
53
53
  except Exception: # pragma: no cover - editable/source runs without dist metadata
54
- SDK_VERSION = "0.23.1"
54
+ SDK_VERSION = "0.25.0"
55
55
 
56
56
  # ---------------------------------------------------------------------------
57
57
  # Environment configuration
@@ -95,13 +95,26 @@ def _build_email_payload(
95
95
  raise ValidationError("to is required unless reply_to_email_id is set", "to")
96
96
  if not subject:
97
97
  raise ValidationError("subject is required unless reply_to_email_id is set", "subject")
98
- mailbox = from_mailbox or "agent"
99
- domain = from_domain or "oneshotagent.com"
98
+ # Rotation mode: when the caller passes neither from_domain nor
99
+ # from_mailbox, omit from_address entirely so the server picks from the
100
+ # agent's domain pool (mirrors the TS SDK's rotation contract). Pinning
101
+ # from_mailbox alone has no domain to attach it to — "oneshotagent.com"
102
+ # is OneShot's own domain and no agent can send from it — so that
103
+ # combination is a client-side error rather than a guaranteed
104
+ # domain_not_owned rejection from the server.
100
105
  payload: dict[str, Any] = {
101
- "from_address": f"{mailbox}@{domain}",
102
106
  "body": body,
103
107
  **extra,
104
108
  }
109
+ if from_domain:
110
+ mailbox = from_mailbox or "agent"
111
+ payload["from_address"] = f"{mailbox}@{from_domain}"
112
+ elif from_mailbox:
113
+ raise ValidationError(
114
+ "from_mailbox was pinned without from_domain. Pass from_domain (a domain you own) as well, "
115
+ "or omit both to rotate across your warmed domains.",
116
+ "from_domain",
117
+ )
105
118
  if to is not None:
106
119
  payload["to_address"] = to
107
120
  if subject is not None:
@@ -299,12 +312,16 @@ class OneShotClient:
299
312
  # Headers
300
313
  # ------------------------------------------------------------------
301
314
 
302
- def _headers(self, max_cost: Optional[float] = None) -> dict[str, str]:
315
+ def _headers(self, max_cost: Optional[float] = None, idempotency_key: Optional[str] = None) -> dict[str, str]:
303
316
  headers = {
304
317
  "Content-Type": "application/json",
305
318
  "X-Agent-ID": self.address,
306
319
  "X-OneShot-SDK-Version": SDK_VERSION,
307
320
  }
321
+ # Sent on BOTH x402 legs: the server replays a retry instead of
322
+ # settling a second payment and repeating the side effect.
323
+ if idempotency_key:
324
+ headers["Idempotency-Key"] = idempotency_key
308
325
  # Server-side budget cap. The API rejects with HTTP 400
309
326
  # `exceeds_caller_budget` before requesting payment if the quoted
310
327
  # total exceeds this cap. The local fast-fail below stays as defense
@@ -318,16 +335,17 @@ class OneShotClient:
318
335
  from oneshot.physical_mail import PhysicalMail
319
336
  return PhysicalMail(self)
320
337
 
321
- def _read_headers(self) -> dict[str, str]:
338
+ def _read_headers(self, scope: str = "read") -> dict[str, str]:
322
339
  """Auth headers plus a signed EIP-712 read proof (x-agent-proof) for the
323
340
  free READ routes (inbox, sms inbox, notifications, balance, browser
324
341
  profiles). These identify the caller by wallet, and wallet addresses are
325
342
  public, so without a proof anyone could read another agent's data by
326
343
  supplying its address. Signing failure falls back to plain headers (the
327
- server runs log-only until read-proof enforcement is enabled)."""
344
+ server runs log-only until read-proof enforcement is enabled).
345
+ Free MUTATION routes (e.g. LinkedIn connect/revoke) require scope="write"."""
328
346
  headers = self._headers()
329
347
  try:
330
- headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address)
348
+ headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope=scope)
331
349
  except Exception as e: # noqa: BLE001
332
350
  self._log(f"Failed to sign read proof (continuing without): {e}")
333
351
  return headers
@@ -433,6 +451,7 @@ class OneShotClient:
433
451
  wait: bool = True,
434
452
  wait_for_phones: bool = False,
435
453
  phone_timeout_sec: int = 360,
454
+ idempotency_key: Optional[str] = None,
436
455
  ) -> Any:
437
456
  """Execute a paid tool call (blocking). Handles the full x402 flow.
438
457
 
@@ -454,6 +473,7 @@ class OneShotClient:
454
473
  wait=wait,
455
474
  wait_for_phones=wait_for_phones,
456
475
  phone_timeout_sec=phone_timeout_sec,
476
+ idempotency_key=idempotency_key,
457
477
  )
458
478
  )
459
479
 
@@ -505,11 +525,18 @@ class OneShotClient:
505
525
  wait: bool = True,
506
526
  wait_for_phones: bool = False,
507
527
  phone_timeout_sec: int = 360,
528
+ quote_id: Optional[str] = None,
529
+ idempotency_key: Optional[str] = None,
508
530
  ) -> Any:
509
531
  """Execute a paid tool call (async). Handles the full x402 flow.
510
532
 
511
533
  With ``wait=False`` a queued job is returned as
512
534
  ``{"request_id": ..., "status": ...}`` instead of being polled.
535
+
536
+ Pass ``quote_id`` when the caller already obtained a quote for this
537
+ exact request (e.g. ``/email/send`` after ``/email/quote``) — it rides
538
+ on the ``x-quote-id`` header from the very first attempt, since
539
+ quote-based routes reject before the 402 dance even starts without it.
513
540
  """
514
541
  memo = payload.get("memo")
515
542
  if memo is not None:
@@ -540,8 +567,15 @@ class OneShotClient:
540
567
  await self.aensure_budgets_synced()
541
568
 
542
569
  async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
543
- # Step 1 — Initial POST (expect 402 for paid tools)
544
- resp = await client.post(url, headers=self._headers(max_cost=max_cost), json=payload)
570
+ # Step 1 — Initial POST (expect 402 for paid tools). Quote-based
571
+ # routes (e.g. /email/send) reject with 400 quote_id_required
572
+ # before they ever get to the 402 dance unless x-quote-id rides
573
+ # on this very first attempt, so an already-obtained quote_id
574
+ # must be sent from the start, not just on the paid retry.
575
+ initial_headers = self._headers(max_cost=max_cost, idempotency_key=idempotency_key)
576
+ if quote_id:
577
+ initial_headers["x-quote-id"] = quote_id
578
+ resp = await client.post(url, headers=initial_headers, json=payload)
545
579
 
546
580
  # The spend-budget gate rejects at quote time (403) so nothing is
547
581
  # signed; surface it as the typed error.
@@ -597,7 +631,12 @@ class OneShotClient:
597
631
  quote_data = resp.json()
598
632
  payment_request = quote_data["payment_request"]
599
633
  context = quote_data.get("context", {})
600
- quote_id = context.get("quote_id")
634
+ # Self-quoting tools (voice/sms/browser/compute) mint a fresh
635
+ # quote_id in this same 402's context; quote-then-send tools
636
+ # (email/send) already had one supplied by the caller and don't
637
+ # get one echoed back here — keep it rather than clobbering it
638
+ # with None, or the paid retry below loses x-quote-id entirely.
639
+ quote_id = context.get("quote_id") or quote_id
601
640
 
602
641
  total = context.get("total") or context.get("pricing", {}).get("total")
603
642
  if max_cost is not None and total is not None:
@@ -622,7 +661,7 @@ class OneShotClient:
622
661
  extensions=parsed_req.get("extensions"),
623
662
  )
624
663
  headers = {
625
- **self._headers(max_cost=max_cost),
664
+ **self._headers(max_cost=max_cost, idempotency_key=idempotency_key),
626
665
  "payment-signature": encode_payment_header(auth),
627
666
  }
628
667
  if quote_id:
@@ -644,7 +683,7 @@ class OneShotClient:
644
683
 
645
684
  # Step 4 — Re-POST with payment headers (x402 format)
646
685
  headers = {
647
- **self._headers(max_cost=max_cost),
686
+ **self._headers(max_cost=max_cost, idempotency_key=idempotency_key),
648
687
  "payment-signature": encode_payment_header(auth),
649
688
  }
650
689
  if quote_id:
@@ -719,21 +758,25 @@ class OneShotClient:
719
758
  self,
720
759
  endpoint: str,
721
760
  payload: Optional[dict[str, Any]] = None,
761
+ *,
762
+ scope: str = "read",
722
763
  ) -> Any:
723
- """POST to a free endpoint (blocking)."""
764
+ """POST to a free endpoint (blocking). Mutations pass scope="write"."""
724
765
  return asyncio.get_event_loop().run_until_complete(
725
- self.acall_free_post(endpoint, payload)
766
+ self.acall_free_post(endpoint, payload, scope=scope)
726
767
  )
727
768
 
728
769
  async def acall_free_post(
729
770
  self,
730
771
  endpoint: str,
731
772
  payload: Optional[dict[str, Any]] = None,
773
+ *,
774
+ scope: str = "read",
732
775
  ) -> Any:
733
- """POST to a free endpoint (async)."""
776
+ """POST to a free endpoint (async). Mutations pass scope="write"."""
734
777
  url = f"{self.base_url}{endpoint}"
735
- async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
736
- resp = await client.post(url, headers=self._read_headers(), json=payload or {})
778
+ async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
779
+ resp = await client.post(url, headers=self._read_headers(scope), json=payload or {})
737
780
  if not resp.is_success:
738
781
  raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
739
782
  return resp.json()
@@ -790,20 +833,24 @@ class OneShotClient:
790
833
  def call_free_delete(
791
834
  self,
792
835
  endpoint: str,
836
+ *,
837
+ scope: str = "read",
793
838
  ) -> Any:
794
- """DELETE a free endpoint (blocking)."""
839
+ """DELETE a free endpoint (blocking). Mutations pass scope="write"."""
795
840
  return asyncio.get_event_loop().run_until_complete(
796
- self.acall_free_delete(endpoint)
841
+ self.acall_free_delete(endpoint, scope=scope)
797
842
  )
798
843
 
799
844
  async def acall_free_delete(
800
845
  self,
801
846
  endpoint: str,
847
+ *,
848
+ scope: str = "read",
802
849
  ) -> Any:
803
- """DELETE a free endpoint (async)."""
850
+ """DELETE a free endpoint (async). Mutations pass scope="write"."""
804
851
  url = f"{self.base_url}{endpoint}"
805
852
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
806
- resp = await client.delete(url, headers=self._read_headers())
853
+ resp = await client.delete(url, headers=self._read_headers(scope))
807
854
  if not resp.is_success:
808
855
  raise ToolError(f"DELETE {endpoint} failed", resp.status_code, resp.text)
809
856
  if resp.status_code == 204 or not resp.text:
@@ -1052,6 +1099,10 @@ class OneShotClient:
1052
1099
  timeout_sec: int = 300,
1053
1100
  ) -> Any:
1054
1101
  """Run a browser automation task. Async."""
1102
+ if secrets is not None:
1103
+ raise ValueError("Browser secrets are unsupported; import cookies or storage_state into a profile")
1104
+ if max_steps is not None and (type(max_steps) is not int or not 25 <= max_steps <= 100):
1105
+ raise ValueError("max_steps must be an integer between 25 and 100")
1055
1106
  payload: dict[str, Any] = {"task": task}
1056
1107
  if output_schema is not None:
1057
1108
  payload["output_schema"] = output_schema
@@ -1061,21 +1112,53 @@ class OneShotClient:
1061
1112
  payload["allowed_domains"] = allowed_domains
1062
1113
  if profile_id is not None:
1063
1114
  payload["profile_id"] = profile_id
1064
- if secrets is not None:
1065
- payload["secrets"] = secrets
1066
1115
  if max_steps is not None:
1067
1116
  payload["max_steps"] = max_steps
1068
1117
  return await self.acall_tool(
1069
1118
  "/v1/tools/browser", payload, max_cost=max_cost, timeout_sec=timeout_sec,
1070
1119
  )
1071
1120
 
1072
- def create_browser_profile(self, name: str) -> dict:
1121
+ def create_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
1073
1122
  """Create a persistent browser profile. Blocking."""
1074
- return self.call_free_post("/v1/tools/browser/profiles", {"name": name})
1075
-
1076
- async def acreate_browser_profile(self, name: str) -> dict:
1123
+ if cookies is not None and storage_state is not None:
1124
+ raise ValueError("Provide cookies or storage_state, not both")
1125
+ payload = {"name": name}
1126
+ if cookies is not None:
1127
+ payload["cookies"] = cookies
1128
+ if storage_state is not None:
1129
+ payload["storage_state"] = storage_state
1130
+ return self.call_free_post("/v1/tools/browser/profiles", payload)
1131
+
1132
+ async def acreate_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
1077
1133
  """Create a persistent browser profile. Async."""
1078
- return await self.acall_free_post("/v1/tools/browser/profiles", {"name": name})
1134
+ if cookies is not None and storage_state is not None:
1135
+ raise ValueError("Provide cookies or storage_state, not both")
1136
+ payload = {"name": name}
1137
+ if cookies is not None:
1138
+ payload["cookies"] = cookies
1139
+ if storage_state is not None:
1140
+ payload["storage_state"] = storage_state
1141
+ return await self.acall_free_post("/v1/tools/browser/profiles", payload)
1142
+
1143
+ def start_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
1144
+ """Open login/2FA live view. Do not log the returned live_url."""
1145
+ return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
1146
+
1147
+ async def astart_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
1148
+ return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
1149
+
1150
+ def get_browser_profile_setup(self, profile_id: str) -> dict:
1151
+ return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
1152
+
1153
+ async def aget_browser_profile_setup(self, profile_id: str) -> dict:
1154
+ return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
1155
+
1156
+ def finish_browser_profile_setup(self, profile_id: str) -> dict:
1157
+ """Save after human login/2FA; inspect saved cookies before navigation."""
1158
+ return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
1159
+
1160
+ async def afinish_browser_profile_setup(self, profile_id: str) -> dict:
1161
+ return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
1079
1162
 
1080
1163
  def list_browser_profiles(self) -> list:
1081
1164
  """List all browser profiles. Blocking."""
@@ -1093,6 +1176,107 @@ class OneShotClient:
1093
1176
  """Delete a browser profile. Async."""
1094
1177
  return await self.acall_free_delete(f"/v1/tools/browser/profiles/{profile_id}")
1095
1178
 
1179
+ # ------------------------------------------------------------------
1180
+ # LinkedIn — messaging through a human-connected account (issue #756)
1181
+ # ------------------------------------------------------------------
1182
+
1183
+ def linkedin_connect(self, requested_actions: list[str], *, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
1184
+ """Create a hosted LinkedIn login link for a human to connect THEIR OWN account and grant `requested_actions`
1185
+ (read, reply, view_profile, react). The returned url is shown to the human once and expires in 30 minutes. Free."""
1186
+ return self.call_free_post("/v1/tools/linkedin/connect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
1187
+
1188
+ async def alinkedin_connect(self, requested_actions: list[str], *, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
1189
+ return await self.acall_free_post("/v1/tools/linkedin/connect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
1190
+
1191
+ def get_linkedin_connection(self, intent_id: str) -> dict:
1192
+ """Status of a connection intent: pending | verifying | completed (with `account`) | failed (`failure_reason`). Free."""
1193
+ return self.call_free_get(f"/v1/tools/linkedin/connect/{quote(intent_id, safe='')}")
1194
+
1195
+ async def aget_linkedin_connection(self, intent_id: str) -> dict:
1196
+ return await self.acall_free_get(f"/v1/tools/linkedin/connect/{quote(intent_id, safe='')}")
1197
+
1198
+ def list_linkedin_accounts(self, *, include_revoked: bool = False) -> dict:
1199
+ """Connected LinkedIn accounts for this agent (`{"accounts": [...]}`). Never exposes upstream ids. Free."""
1200
+ return self.call_free_get("/v1/tools/linkedin/accounts", {"include_revoked": "true"} if include_revoked else None)
1201
+
1202
+ async def alist_linkedin_accounts(self, *, include_revoked: bool = False) -> dict:
1203
+ return await self.acall_free_get("/v1/tools/linkedin/accounts", {"include_revoked": "true"} if include_revoked else None)
1204
+
1205
+ def get_linkedin_account(self, account_id: str) -> dict:
1206
+ return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}")
1207
+
1208
+ async def aget_linkedin_account(self, account_id: str) -> dict:
1209
+ return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}")
1210
+
1211
+ def reconnect_linkedin_account(self, account_id: str, *, requested_actions: Optional[list[str]] = None, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
1212
+ """Hosted reconnect link for an existing account (keeps ownership; may narrow, never widen, the grant). Free."""
1213
+ return self.call_free_post(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/reconnect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
1214
+
1215
+ async def areconnect_linkedin_account(self, account_id: str, *, requested_actions: Optional[list[str]] = None, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
1216
+ return await self.acall_free_post(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/reconnect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
1217
+
1218
+ def revoke_linkedin_account(self, account_id: str) -> dict:
1219
+ """End the grant, cancel queued writes, delete the upstream connection. Free."""
1220
+ return self.call_free_delete(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}", scope="write")
1221
+
1222
+ async def arevoke_linkedin_account(self, account_id: str) -> dict:
1223
+ return await self.acall_free_delete(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}", scope="write")
1224
+
1225
+ def get_linkedin_sync(self, account_id: str) -> dict:
1226
+ """Coverage, sync_state and recent runs for an account. Free."""
1227
+ return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/sync")
1228
+
1229
+ async def aget_linkedin_sync(self, account_id: str) -> dict:
1230
+ return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/sync")
1231
+
1232
+ def linkedin_sync(self, account_id: str, *, mode: Optional[str] = None, max_pages: Optional[int] = None, max_cost: Optional[float] = None, timeout_sec: int = 600, wait: bool = True, **kwargs: Any) -> Any:
1233
+ """One bounded, paid history-sync run (up to max_pages x 250 messages). Blocking."""
1234
+ return self.call_tool("/v1/tools/linkedin/sync", _linkedin_sync_payload(account_id, mode, max_pages, kwargs), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
1235
+
1236
+ async def alinkedin_sync(self, account_id: str, *, mode: Optional[str] = None, max_pages: Optional[int] = None, max_cost: Optional[float] = None, timeout_sec: int = 600, wait: bool = True, **kwargs: Any) -> Any:
1237
+ return await self.acall_tool("/v1/tools/linkedin/sync", _linkedin_sync_payload(account_id, mode, max_pages, kwargs), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
1238
+
1239
+ def linkedin_conversations(self, account_id: str, **params: Any) -> dict:
1240
+ """Synced conversations, newest activity first; carries coverage + sync_state. Params: cursor, limit, since, unread. Free."""
1241
+ return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/conversations", _linkedin_query(params))
1242
+
1243
+ async def alinkedin_conversations(self, account_id: str, **params: Any) -> dict:
1244
+ return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/conversations", _linkedin_query(params))
1245
+
1246
+ def linkedin_messages(self, account_id: str, **params: Any) -> dict:
1247
+ """Synced messages, newest first. Params: conversation_id, direction, since, changed_since, cursor, limit, include_deleted. Free."""
1248
+ return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/messages", _linkedin_query(params))
1249
+
1250
+ async def alinkedin_messages(self, account_id: str, **params: Any) -> dict:
1251
+ return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/messages", _linkedin_query(params))
1252
+
1253
+ def linkedin_reply(self, account_id: str, conversation_id: str, text: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1254
+ """Send a reply through the connected account. An Idempotency-Key is generated when not supplied so a retry never double-sends. Blocking."""
1255
+ return self.call_tool("/v1/tools/linkedin/reply", {"account_id": account_id, "conversation_id": conversation_id, "text": text, **kwargs},
1256
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1257
+
1258
+ async def alinkedin_reply(self, account_id: str, conversation_id: str, text: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1259
+ return await self.acall_tool("/v1/tools/linkedin/reply", {"account_id": account_id, "conversation_id": conversation_id, "text": text, **kwargs},
1260
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1261
+
1262
+ def linkedin_view_profile(self, account_id: str, identifier: str, *, notify: bool = False, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1263
+ """View a profile through the connected account (public-safe summary). notify=True cannot be undone. Blocking."""
1264
+ return self.call_tool("/v1/tools/linkedin/profile-view", {"account_id": account_id, "identifier": identifier, "notify": bool(notify), **kwargs},
1265
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1266
+
1267
+ async def alinkedin_view_profile(self, account_id: str, identifier: str, *, notify: bool = False, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1268
+ return await self.acall_tool("/v1/tools/linkedin/profile-view", {"account_id": account_id, "identifier": identifier, "notify": bool(notify), **kwargs},
1269
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1270
+
1271
+ def linkedin_react(self, account_id: str, post_id: str, *, reaction_type: str = "like", idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1272
+ """React to a post through the connected account. Blocking."""
1273
+ return self.call_tool("/v1/tools/linkedin/react", {"account_id": account_id, "post_id": post_id, "reaction_type": reaction_type, **kwargs},
1274
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1275
+
1276
+ async def alinkedin_react(self, account_id: str, post_id: str, *, reaction_type: str = "like", idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1277
+ return await self.acall_tool("/v1/tools/linkedin/react", {"account_id": account_id, "post_id": post_id, "reaction_type": reaction_type, **kwargs},
1278
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1279
+
1096
1280
  # ------------------------------------------------------------------
1097
1281
  # Paid tool convenience methods
1098
1282
  # ------------------------------------------------------------------
@@ -1160,9 +1344,13 @@ class OneShotClient:
1160
1344
  ``mailbox_mode == 'mailbox'``, and read ``mailbox_provisioning_fee`` on
1161
1345
  the quote for the exact amount before paying.
1162
1346
  """
1163
- return self.call_tool(
1164
- "/v1/tools/email/send",
1165
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode),
1347
+ return asyncio.get_event_loop().run_until_complete(
1348
+ self.aemail(
1349
+ to, subject, body,
1350
+ from_domain=from_domain, from_mailbox=from_mailbox, from_name=from_name,
1351
+ reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
1352
+ **kwargs,
1353
+ )
1166
1354
  )
1167
1355
 
1168
1356
  async def aemail(
@@ -1186,12 +1374,53 @@ class OneShotClient:
1186
1374
 
1187
1375
  ``mailbox_mode`` ('relay' | 'mailbox', default 'relay') selects how a
1188
1376
  domain sends — see ``email()`` for details.
1377
+
1378
+ Quotes before sending — mirrors the TS SDK (``index.ts`` ``email()``).
1379
+ ``/v1/tools/email/send`` requires an ``x-quote-id`` from a prior
1380
+ ``/v1/tools/email/quote`` call and 400s without one, and rotation mode
1381
+ (both ``from_domain``/``from_mailbox`` omitted) is decided at quote
1382
+ time and locked server-side for the matching send — so quoting first
1383
+ is not optional, it's the only way rotation (or any send) works.
1189
1384
  """
1385
+ # max_cost/timeout_sec are transport options for acall_tool, not
1386
+ # send-body fields — pull them out of kwargs before they reach
1387
+ # _build_email_payload, or they'd ride along in the JSON body (which
1388
+ # the server's Zod schema silently drops) instead of driving the
1389
+ # X-Max-Cost-USDC header + local budget check and the poll timeout.
1390
+ max_cost = kwargs.pop("max_cost", None)
1391
+ timeout_sec = kwargs.pop("timeout_sec", 120)
1392
+
1393
+ # Quote payload mirrors the TS SDK's quote body: no from_name/extra
1394
+ # kwargs (attachments, idempotencyKey, etc. are send-only concerns).
1395
+ quote_payload = _build_email_payload(
1396
+ to, subject, body, from_domain, from_mailbox, None, {},
1397
+ reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
1398
+ )
1399
+ quote = await self.acall_tool(
1400
+ "/v1/tools/email/quote", quote_payload, max_cost=max_cost,
1401
+ )
1402
+ quote_id = quote.get("quote_id") if isinstance(quote, dict) else None
1403
+ quoted_from_address = quote.get("from_address") if isinstance(quote, dict) else None
1404
+
1405
+ send_payload = _build_email_payload(
1406
+ to, subject, body, from_domain, from_mailbox, from_name, kwargs,
1407
+ reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
1408
+ )
1409
+ # Replay the quote-locked/rotated address explicitly. The server
1410
+ # already replays it from the quote row when from_address is absent,
1411
+ # but sending it back is harmless and mirrors the TS SDK
1412
+ # (index.ts:425,431) so a future /send-without-quote revision keeps
1413
+ # working unchanged.
1414
+ resolved_from_address = send_payload.get("from_address") or quoted_from_address
1415
+ if resolved_from_address:
1416
+ send_payload["from_address"] = resolved_from_address
1417
+
1190
1418
  return await self.acall_tool(
1191
- "/v1/tools/email/send",
1192
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode),
1419
+ "/v1/tools/email/send", send_payload, quote_id=quote_id,
1420
+ max_cost=max_cost, timeout_sec=timeout_sec,
1193
1421
  )
1194
1422
 
1423
+
1195
1424
  def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
1196
1425
  """Make an AI voice call. Blocking."""
1197
1426
  return self.call_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
@@ -1556,6 +1785,7 @@ class OneShotClient:
1556
1785
  f"Job failed: {job.get('error', 'Unknown')}",
1557
1786
  request_id,
1558
1787
  str(job.get("error", "Unknown")),
1788
+ code=job.get("error_code"), partial_result=job.get("result"),
1559
1789
  )
1560
1790
 
1561
1791
  retries = 0
@@ -1652,3 +1882,37 @@ class OneShotClient:
1652
1882
  # Timeout — return the last snapshot. phones_pending is still True
1653
1883
  # so the caller knows phones never arrived.
1654
1884
  return last_result
1885
+
1886
+
1887
+ # ---------------------------------------------------------------------------
1888
+ # LinkedIn helpers (module-level so the sync/async twins share them)
1889
+ # ---------------------------------------------------------------------------
1890
+
1891
+ def _new_idempotency_key() -> str:
1892
+ import secrets
1893
+ return secrets.token_hex(16)
1894
+
1895
+
1896
+ def _linkedin_connect_payload(requested_actions: Optional[list[str]], success_redirect_url: Optional[str], failure_redirect_url: Optional[str]) -> dict[str, Any]:
1897
+ payload: dict[str, Any] = {}
1898
+ if requested_actions is not None:
1899
+ payload["requested_actions"] = list(requested_actions)
1900
+ if success_redirect_url:
1901
+ payload["success_redirect_url"] = success_redirect_url
1902
+ if failure_redirect_url:
1903
+ payload["failure_redirect_url"] = failure_redirect_url
1904
+ return payload
1905
+
1906
+
1907
+ def _linkedin_sync_payload(account_id: str, mode: Optional[str], max_pages: Optional[int], extra: dict[str, Any]) -> dict[str, Any]:
1908
+ payload: dict[str, Any] = {"account_id": account_id, **extra}
1909
+ if mode:
1910
+ payload["mode"] = mode
1911
+ if max_pages is not None:
1912
+ payload["max_pages"] = int(max_pages)
1913
+ return payload
1914
+
1915
+
1916
+ def _linkedin_query(params: dict[str, Any]) -> Optional[dict[str, str]]:
1917
+ query = {k: ("true" if v is True else "false" if v is False else str(v)) for k, v in params.items() if v is not None}
1918
+ return query or None
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.23.1"
4
- description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools"
3
+ version = "0.25.0"
4
+ description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
7
7
  requires-python = ">=3.10"
@@ -0,0 +1,57 @@
1
+ """Browser credentials bypass paid task prose; invalid requests fail locally."""
2
+ from unittest.mock import AsyncMock, MagicMock
3
+
4
+ import pytest
5
+ from oneshot.client import OneShotClient
6
+ from oneshot._errors import JobError
7
+
8
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ @pytest.mark.parametrize('options', [{'secrets': {}}, {'max_steps': 3}, {'max_steps': 25.5}, {'max_steps': True}])
13
+ async def test_invalid_task_never_calls_paid_endpoint(options):
14
+ client = OneShotClient(TEST_PRIVATE_KEY)
15
+ client.acall_tool = AsyncMock()
16
+ with pytest.raises(ValueError):
17
+ await client.abrowser('Read heading', **options)
18
+ client.acall_tool.assert_not_awaited()
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_import_uses_profile_endpoint_and_rejects_ambiguous_state():
23
+ client = OneShotClient(TEST_PRIVATE_KEY)
24
+ client.acall_free_post = AsyncMock(return_value={'id': 'profile'})
25
+ state = {'cookies': [], 'origins': []}
26
+ assert await client.acreate_browser_profile('test', storage_state=state) == {'id': 'profile'}
27
+ client.acall_free_post.assert_awaited_once_with('/v1/tools/browser/profiles', {'name': 'test', 'storage_state': state})
28
+ with pytest.raises(ValueError):
29
+ await client.acreate_browser_profile('test', cookies=[], storage_state=state)
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_failed_poll_preserves_partial_browser_result():
34
+ client = OneShotClient(TEST_PRIVATE_KEY)
35
+ partial = {'cost': .1, 'steps': [{'number': 1, 'goal': 'Browser action', 'url': 'https://example.com'}]}
36
+ response = MagicMock(is_success=True, status_code=200)
37
+ response.json.return_value = {'status': 'failed', 'error_message': 'Browser timeout', 'error_code': 'browser_timeout', 'result': partial}
38
+ http = MagicMock()
39
+ http.get = AsyncMock(return_value=response)
40
+ with pytest.raises(JobError) as caught:
41
+ await client._poll_job(http, 'request', 10)
42
+ assert caught.value.code == 'browser_timeout'
43
+ assert caught.value.partial_result == partial
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_interactive_setup_uses_free_authenticated_operations():
48
+ client = OneShotClient(TEST_PRIVATE_KEY)
49
+ client.acall_free_post = AsyncMock(return_value={'status': 'idle'})
50
+ await client.astart_browser_profile_setup('profile', 'https://example.com/login')
51
+ await client.aget_browser_profile_setup('profile')
52
+ await client.afinish_browser_profile_setup('profile')
53
+ assert [(call.args[0],call.args[1]) for call in client.acall_free_post.await_args_list] == [
54
+ ('/v1/tools/browser/profiles/profile/setup/start', {'start_url':'https://example.com/login'}),
55
+ ('/v1/tools/browser/profiles/profile/setup/status', {}),
56
+ ('/v1/tools/browser/profiles/profile/setup/finish', {}),
57
+ ]
@@ -0,0 +1,282 @@
1
+ """Tests for the email payload construction in OneShotClient.
2
+
3
+ Verifies that email()/aemail() send the API's field convention
4
+ (from_address + to_address), build from_address from from_mailbox/from_domain
5
+ when a domain is pinned, omit from_address entirely to trigger server-side
6
+ rotation when both are omitted, reject a from_mailbox pinned without
7
+ from_domain, and forward the optional from_name display name.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from typing import Any
14
+
15
+ import pytest
16
+
17
+ from oneshot._errors import ValidationError
18
+ from oneshot.client import OneShotClient, _build_email_payload
19
+
20
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
21
+ REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def _fresh_event_loop():
26
+ """Give every test in this module its own event loop.
27
+
28
+ The sync surface (``email()``) bridges to ``aemail()`` via
29
+ ``asyncio.get_event_loop().run_until_complete(...)``. pytest-asyncio
30
+ clears the current event loop after each async test, which otherwise
31
+ breaks any *sync* test — mocked or not — that happens to run afterward
32
+ in the same session ("There is no current event loop in thread
33
+ 'MainThread'"). Without this fixture the suite's pass/fail would depend
34
+ on test collection order, which is exactly the kind of environment-
35
+ dependent test this correction round was asked to avoid introducing.
36
+ """
37
+ loop = asyncio.new_event_loop()
38
+ asyncio.set_event_loop(loop)
39
+ yield
40
+ loop.close()
41
+
42
+
43
+ # ── _build_email_payload (pure) ───────────────────────────────────────────
44
+
45
+ def test_payload_omits_from_address_when_no_from_knobs_given():
46
+ # Rotation mode: matches the TS SDK's contract — never fabricate
47
+ # `agent@oneshotagent.com`. The server picks a domain from the agent's
48
+ # own pool and the caller replays whatever the quote returns.
49
+ p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
50
+ assert p == {
51
+ "to_address": "r@x.com",
52
+ "subject": "S",
53
+ "body": "B",
54
+ }
55
+ assert "from_address" not in p
56
+
57
+
58
+ def test_payload_uses_mailbox_domain_and_name():
59
+ p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
60
+ assert p["from_address"] == "jane@acme.com"
61
+ assert p["from_name"] == "Jane Doe"
62
+
63
+
64
+ def test_payload_from_domain_alone_defaults_mailbox_to_agent():
65
+ p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
66
+ assert p["from_address"] == "agent@acme.com"
67
+
68
+
69
+ def test_payload_from_mailbox_without_from_domain_raises():
70
+ # No default domain to fall back to — oneshotagent.com is OneShot's own
71
+ # domain and no agent can send from it. This must be a client-side
72
+ # ValidationError naming from_domain, never a fabricated address.
73
+ with pytest.raises(ValidationError, match="from_domain"):
74
+ _build_email_payload("r@x.com", "S", "B", None, "jane", None, {})
75
+
76
+
77
+ def test_payload_omits_empty_from_name():
78
+ p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
79
+ assert "from_name" not in p
80
+
81
+
82
+ def test_payload_passes_extra_kwargs():
83
+ p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
84
+ assert p["wait"] is True
85
+
86
+
87
+ def test_payload_forwards_mailbox_mode():
88
+ # mailbox_mode is an explicit keyword-only param, forwarded into the send body so
89
+ # the API's per-domain provider override + fee logic sees it.
90
+ p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {}, mailbox_mode="mailbox")
91
+ assert p["mailbox_mode"] == "mailbox"
92
+
93
+
94
+ def test_payload_omits_mailbox_mode_when_absent():
95
+ p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
96
+ assert "mailbox_mode" not in p
97
+
98
+
99
+ # ── reply threading (reply_to_email_id) ───────────────────────────────────
100
+
101
+ def test_reply_payload_omits_to_and_subject_when_derivable():
102
+ # Replying: to/subject may be omitted; server derives them.
103
+ p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
104
+ assert p["reply_to_email_id"] == REPLY_ID
105
+ assert p["body"] == "B"
106
+ assert "to_address" not in p
107
+ assert "subject" not in p
108
+
109
+
110
+ def test_reply_payload_keeps_explicit_to_and_subject():
111
+ p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
112
+ assert p["reply_to_email_id"] == REPLY_ID
113
+ assert p["to_address"] == "r@x.com"
114
+ assert p["subject"] == "Custom"
115
+
116
+
117
+ def test_non_reply_requires_to():
118
+ with pytest.raises(ValidationError):
119
+ _build_email_payload(None, "S", "B", None, None, None, {})
120
+
121
+
122
+ def test_non_reply_requires_subject():
123
+ with pytest.raises(ValidationError):
124
+ _build_email_payload("r@x.com", None, "B", None, None, None, {})
125
+
126
+
127
+ def test_reply_does_not_require_to_or_subject():
128
+ # Should not raise even though to and subject are None.
129
+ _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
130
+
131
+
132
+ # ── email()/aemail() quote-then-send flow ──────────────────────────────────
133
+ #
134
+ # email()/aemail() now quote before sending (mirrors the TS SDK's
135
+ # email/quote -> email/send flow in libs/agent-sdk/src/index.ts). These tests
136
+ # stub acall_tool itself (the real HTTP/x402 transport) so they exercise the
137
+ # actual two-request flow end-to-end, instead of stopping at the payload
138
+ # builder — the gap that let a Python send hard-fail with 400
139
+ # missing_from_address in production while the mocked-call_tool tests still
140
+ # passed.
141
+
142
+ def _capturing_client(
143
+ quote_from_address: str | None = None,
144
+ quote_extra: dict | None = None,
145
+ ) -> tuple[OneShotClient, list[dict]]:
146
+ """A client whose acall_tool is stubbed to capture both the /email/quote
147
+ and /email/send calls it makes, without touching the network."""
148
+ c = OneShotClient(TEST_PRIVATE_KEY)
149
+ calls: list[dict] = []
150
+
151
+ async def fake_acall_tool(endpoint, payload, **kwargs):
152
+ calls.append({
153
+ "endpoint": endpoint,
154
+ "payload": dict(payload),
155
+ "quote_id": kwargs.get("quote_id"),
156
+ "max_cost": kwargs.get("max_cost"),
157
+ "timeout_sec": kwargs.get("timeout_sec"),
158
+ })
159
+ if endpoint.endswith("/email/quote"):
160
+ resp: dict[str, Any] = {"total_cost": "0.01", "quote_id": "quote_test_1"}
161
+ if quote_from_address:
162
+ resp["from_address"] = quote_from_address
163
+ if quote_extra:
164
+ resp.update(quote_extra)
165
+ return resp
166
+ return {"request_id": "r1", "status": "completed"}
167
+
168
+ c.acall_tool = fake_acall_tool # type: ignore[method-assign]
169
+ return c, calls
170
+
171
+
172
+ def _find(calls: list[dict], suffix: str) -> dict:
173
+ for call in calls:
174
+ if call["endpoint"].endswith(suffix):
175
+ return call
176
+ raise AssertionError(f"no call to endpoint ending in {suffix!r} among {[c['endpoint'] for c in calls]}")
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_aemail_rotation_quotes_before_send_and_replays_picked_address():
181
+ # Rotation mode (no from_domain/from_mailbox): from_address must be
182
+ # omitted on the quote so the server picks a domain from the agent's own
183
+ # pool, and the picked address must be replayed on /send — otherwise
184
+ # /send 400s with missing_from_address (apps/api-service's
185
+ # /email/send resolves from_address only from an explicit param, a
186
+ # quote-locked address, or a reply target).
187
+ c, calls = _capturing_client(quote_from_address="agent@picked.com")
188
+ await c.aemail("r@x.com", "Hi", "Body")
189
+
190
+ quote = _find(calls, "/email/quote")
191
+ send = _find(calls, "/email/send")
192
+ assert "from_address" not in quote["payload"]
193
+ assert send["payload"]["from_address"] == "agent@picked.com"
194
+ assert send["payload"]["to_address"] == "r@x.com"
195
+ # /email/send requires x-quote-id from the very first attempt (the API
196
+ # 400s quote_id_required / "Quote ID required" otherwise) — acall_tool
197
+ # must receive the quote's id so it can set the header on every attempt.
198
+ assert send["quote_id"] == "quote_test_1"
199
+
200
+
201
+ def test_email_sends_correct_contract():
202
+ c, calls = _capturing_client()
203
+ c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
204
+ quote = _find(calls, "/email/quote")
205
+ send = _find(calls, "/email/send")
206
+ assert quote["payload"]["from_address"] == "jane@acme.com"
207
+ assert send["payload"]["from_address"] == "jane@acme.com"
208
+ assert send["payload"]["to_address"] == "r@x.com"
209
+ assert send["payload"]["from_name"] == "Jane Doe"
210
+ # from_name is a display-only field for the send, not part of pricing —
211
+ # mirrors the TS SDK quote body, which never sends it either.
212
+ assert "from_name" not in quote["payload"]
213
+ assert send["quote_id"] == "quote_test_1"
214
+
215
+
216
+ def test_email_reply_forwards_reply_id_without_to_subject():
217
+ c, calls = _capturing_client(quote_from_address="agent@picked.com")
218
+ c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
219
+ quote = _find(calls, "/email/quote")
220
+ send = _find(calls, "/email/send")
221
+ for call in (quote, send):
222
+ assert call["payload"]["reply_to_email_id"] == REPLY_ID
223
+ assert call["payload"]["body"] == "Thanks!"
224
+ assert "to_address" not in call["payload"]
225
+ assert "subject" not in call["payload"]
226
+
227
+
228
+ def test_email_without_to_or_reply_id_raises():
229
+ c, calls = _capturing_client()
230
+ with pytest.raises(ValidationError):
231
+ c.email(body="orphan body")
232
+ assert calls == [] # fails before any network call — quote or send
233
+
234
+
235
+ def test_email_from_mailbox_without_from_domain_raises():
236
+ # Public email() surface, not just the pure _build_email_payload helper:
237
+ # pinning from_mailbox alone must fail client-side naming from_domain,
238
+ # never send agent@oneshotagent.com — checked before quoting, so no
239
+ # network call is made at all.
240
+ c, calls = _capturing_client()
241
+ with pytest.raises(ValidationError, match="from_domain"):
242
+ c.email("r@x.com", "Hi", "Body", from_mailbox="jane")
243
+ assert calls == []
244
+
245
+
246
+ # ── max_cost / timeout_sec forwarding (finding PRRT_kwDOQhndvM6gpNlg) ──────
247
+ #
248
+ # aemail() takes max_cost/timeout_sec via **kwargs alongside genuine send-body
249
+ # extras (attachments, idempotencyKey, ...). Before this fix they fell straight
250
+ # through to _build_email_payload and rode along in the JSON body — never
251
+ # reaching acall_tool, so the X-Max-Cost-USDC header + local budget check never
252
+ # fired on the quote or send call, and the poll used the 120s default no
253
+ # matter what the caller asked for.
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_aemail_forwards_max_cost_and_timeout_to_transport_not_body():
257
+ c, calls = _capturing_client(quote_from_address="agent@picked.com")
258
+ await c.aemail("r@x.com", "Hi", "Body", max_cost=0.05, timeout_sec=30)
259
+
260
+ quote = _find(calls, "/email/quote")
261
+ send = _find(calls, "/email/send")
262
+ # Reached acall_tool as transport options...
263
+ assert quote["max_cost"] == 0.05
264
+ assert send["max_cost"] == 0.05
265
+ assert send["timeout_sec"] == 30
266
+ # ...and did NOT leak into the JSON payload either call sends.
267
+ assert "max_cost" not in quote["payload"]
268
+ assert "max_cost" not in send["payload"]
269
+ assert "timeout_sec" not in quote["payload"]
270
+ assert "timeout_sec" not in send["payload"]
271
+
272
+
273
+ @pytest.mark.asyncio
274
+ async def test_aemail_default_timeout_sec_is_120_when_unset():
275
+ c, calls = _capturing_client(quote_from_address="agent@picked.com")
276
+ await c.aemail("r@x.com", "Hi", "Body")
277
+
278
+ send = _find(calls, "/email/send")
279
+ assert send["timeout_sec"] == 120
280
+ quote = _find(calls, "/email/quote")
281
+ assert quote["max_cost"] is None
282
+
@@ -0,0 +1,72 @@
1
+ """LinkedIn client methods route to the right endpoints; writes always carry an Idempotency-Key."""
2
+ from unittest.mock import AsyncMock
3
+
4
+ import pytest
5
+
6
+ from oneshot.client import OneShotClient
7
+
8
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_connect_and_reads_use_free_endpoints():
13
+ client = OneShotClient(TEST_PRIVATE_KEY)
14
+ client.acall_free_post = AsyncMock(return_value={"intent_id": "i1", "url": "https://account.unipile.com/x"})
15
+ client.acall_free_get = AsyncMock(return_value={"accounts": []})
16
+ client.acall_free_delete = AsyncMock(return_value={"status": "revoked"})
17
+
18
+ await client.alinkedin_connect(["read", "reply"], success_redirect_url="https://app/ok")
19
+ client.acall_free_post.assert_awaited_once_with(
20
+ "/v1/tools/linkedin/connect", {"requested_actions": ["read", "reply"], "success_redirect_url": "https://app/ok"}, scope="write"
21
+ )
22
+ await client.aget_linkedin_connection("i 1")
23
+ client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/connect/i%201")
24
+ await client.alist_linkedin_accounts(include_revoked=True)
25
+ client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/accounts", {"include_revoked": "true"})
26
+ await client.alinkedin_messages("acc", direction="inbound", since="2026-09-15T00:00:00Z", include_deleted=False, limit=10)
27
+ client.acall_free_get.assert_awaited_with(
28
+ "/v1/tools/linkedin/accounts/acc/messages",
29
+ {"direction": "inbound", "since": "2026-09-15T00:00:00Z", "include_deleted": "false", "limit": "10"},
30
+ )
31
+ await client.arevoke_linkedin_account("acc")
32
+ client.acall_free_delete.assert_awaited_once_with("/v1/tools/linkedin/accounts/acc", scope="write")
33
+ await client.areconnect_linkedin_account("acc", requested_actions=["read"])
34
+ client.acall_free_post.assert_awaited_with("/v1/tools/linkedin/accounts/acc/reconnect", {"requested_actions": ["read"]}, scope="write")
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_writes_are_paid_and_idempotency_keyed():
39
+ client = OneShotClient(TEST_PRIVATE_KEY)
40
+ client.acall_tool = AsyncMock(return_value={"status": "sent"})
41
+
42
+ await client.alinkedin_reply("acc", "conv", "hi", memo="m")
43
+ args, kwargs = client.acall_tool.await_args
44
+ assert args == ("/v1/tools/linkedin/reply", {"account_id": "acc", "conversation_id": "conv", "text": "hi", "memo": "m"})
45
+ assert len(kwargs["idempotency_key"]) == 32 # auto-generated
46
+
47
+ await client.alinkedin_reply("acc", "conv", "hi", idempotency_key="fixed")
48
+ assert client.acall_tool.await_args.kwargs["idempotency_key"] == "fixed"
49
+
50
+ await client.alinkedin_view_profile("acc", "https://linkedin.com/in/jane", notify=True)
51
+ args, kwargs = client.acall_tool.await_args
52
+ assert args[0] == "/v1/tools/linkedin/profile-view" and args[1]["notify"] is True and kwargs["idempotency_key"]
53
+
54
+ await client.alinkedin_react("acc", "urn:li:activity:1")
55
+ args, _ = client.acall_tool.await_args
56
+ assert args == ("/v1/tools/linkedin/react", {"account_id": "acc", "post_id": "urn:li:activity:1", "reaction_type": "like"})
57
+
58
+ await client.alinkedin_sync("acc", mode="continue", max_pages=5)
59
+ args, kwargs = client.acall_tool.await_args
60
+ assert args == ("/v1/tools/linkedin/sync", {"account_id": "acc", "mode": "continue", "max_pages": 5})
61
+ assert "idempotency_key" not in kwargs # a sync run is not a side-effecting write
62
+
63
+
64
+ def test_free_mutations_sign_a_write_scoped_proof():
65
+ client = OneShotClient(TEST_PRIVATE_KEY)
66
+ assert client._read_headers("write")["x-agent-proof"] != client._read_headers("read")["x-agent-proof"]
67
+
68
+
69
+ def test_idempotency_key_header_is_set_on_paid_requests():
70
+ client = OneShotClient(TEST_PRIVATE_KEY)
71
+ assert client._headers(idempotency_key="k1")["Idempotency-Key"] == "k1"
72
+ assert "Idempotency-Key" not in client._headers()
@@ -549,7 +549,7 @@ wheels = [
549
549
 
550
550
  [[package]]
551
551
  name = "oneshot-python"
552
- version = "0.23.0"
552
+ version = "0.24.0"
553
553
  source = { editable = "." }
554
554
  dependencies = [
555
555
  { name = "eth-account" },
@@ -1,138 +0,0 @@
1
- """Tests for the email payload construction in OneShotClient.
2
-
3
- Verifies that email()/aemail() send the API's field convention
4
- (from_address + to_address), build from_address from from_mailbox/from_domain,
5
- and forward the optional from_name display name.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from unittest.mock import AsyncMock, MagicMock
11
-
12
- import pytest
13
-
14
- from oneshot._errors import ValidationError
15
- from oneshot.client import OneShotClient, _build_email_payload
16
-
17
- TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
18
- REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
19
-
20
-
21
- # ── _build_email_payload (pure) ───────────────────────────────────────────
22
-
23
- def test_payload_defaults_to_agent_mailbox():
24
- p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
25
- assert p == {
26
- "from_address": "agent@oneshotagent.com",
27
- "to_address": "r@x.com",
28
- "subject": "S",
29
- "body": "B",
30
- }
31
-
32
-
33
- def test_payload_uses_mailbox_domain_and_name():
34
- p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
35
- assert p["from_address"] == "jane@acme.com"
36
- assert p["from_name"] == "Jane Doe"
37
-
38
-
39
- def test_payload_omits_empty_from_name():
40
- p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
41
- assert "from_name" not in p
42
-
43
-
44
- def test_payload_passes_extra_kwargs():
45
- p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
46
- assert p["wait"] is True
47
-
48
-
49
- def test_payload_forwards_mailbox_mode():
50
- # mailbox_mode is an explicit keyword-only param, forwarded into the send body so
51
- # the API's per-domain provider override + fee logic sees it.
52
- p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {}, mailbox_mode="mailbox")
53
- assert p["mailbox_mode"] == "mailbox"
54
-
55
-
56
- def test_payload_omits_mailbox_mode_when_absent():
57
- p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
58
- assert "mailbox_mode" not in p
59
-
60
-
61
- # ── reply threading (reply_to_email_id) ───────────────────────────────────
62
-
63
- def test_reply_payload_omits_to_and_subject_when_derivable():
64
- # Replying: to/subject may be omitted; server derives them.
65
- p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
66
- assert p["reply_to_email_id"] == REPLY_ID
67
- assert p["body"] == "B"
68
- assert "to_address" not in p
69
- assert "subject" not in p
70
-
71
-
72
- def test_reply_payload_keeps_explicit_to_and_subject():
73
- p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
74
- assert p["reply_to_email_id"] == REPLY_ID
75
- assert p["to_address"] == "r@x.com"
76
- assert p["subject"] == "Custom"
77
-
78
-
79
- def test_non_reply_requires_to():
80
- with pytest.raises(ValidationError):
81
- _build_email_payload(None, "S", "B", None, None, None, {})
82
-
83
-
84
- def test_non_reply_requires_subject():
85
- with pytest.raises(ValidationError):
86
- _build_email_payload("r@x.com", None, "B", None, None, None, {})
87
-
88
-
89
- def test_reply_does_not_require_to_or_subject():
90
- # Should not raise even though to and subject are None.
91
- _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
92
-
93
-
94
- # ── email() delegation ────────────────────────────────────────────────────
95
-
96
- def _client() -> OneShotClient:
97
- c = OneShotClient(TEST_PRIVATE_KEY)
98
- c.call_tool = MagicMock(return_value={"request_id": "r1"}) # type: ignore[method-assign]
99
- return c
100
-
101
-
102
- def test_email_sends_correct_contract():
103
- c = _client()
104
- c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
105
- endpoint, payload = c.call_tool.call_args[0]
106
- assert endpoint == "/v1/tools/email/send"
107
- assert payload["from_address"] == "jane@acme.com"
108
- assert payload["to_address"] == "r@x.com"
109
- assert payload["from_name"] == "Jane Doe"
110
-
111
-
112
- @pytest.mark.asyncio
113
- async def test_aemail_sends_correct_contract():
114
- c = OneShotClient(TEST_PRIVATE_KEY)
115
- c.acall_tool = AsyncMock(return_value={"request_id": "r2"}) # type: ignore[method-assign]
116
- await c.aemail("r@x.com", "Hi", "Body")
117
- endpoint, payload = c.acall_tool.call_args[0]
118
- assert endpoint == "/v1/tools/email/send"
119
- assert payload["from_address"] == "agent@oneshotagent.com"
120
- assert payload["to_address"] == "r@x.com"
121
- assert "from_name" not in payload
122
-
123
-
124
- def test_email_reply_forwards_reply_id_without_to_subject():
125
- c = _client()
126
- c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
127
- endpoint, payload = c.call_tool.call_args[0]
128
- assert endpoint == "/v1/tools/email/send"
129
- assert payload["reply_to_email_id"] == REPLY_ID
130
- assert payload["body"] == "Thanks!"
131
- assert "to_address" not in payload
132
- assert "subject" not in payload
133
-
134
-
135
- def test_email_without_to_or_reply_id_raises():
136
- c = _client()
137
- with pytest.raises(ValidationError):
138
- c.email(body="orphan body")