oneshot-python 0.23.1__tar.gz → 0.24.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 (30) hide show
  1. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/.gitignore +1 -0
  2. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/PKG-INFO +1 -1
  3. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/README.md +10 -1
  4. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/_errors.py +3 -1
  5. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/client.py +133 -20
  6. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/pyproject.toml +1 -1
  7. oneshot_python-0.24.0/tests/test_browser.py +57 -0
  8. oneshot_python-0.24.0/tests/test_email_payload.py +282 -0
  9. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/uv.lock +1 -1
  10. oneshot_python-0.23.1/tests/test_email_payload.py +0 -138
  11. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/__init__.py +0 -0
  12. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/_types.py +0 -0
  13. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/physical_mail.py +0 -0
  14. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/x402.py +0 -0
  15. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/__init__.py +0 -0
  16. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_balance.py +0 -0
  17. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_budgets.py +0 -0
  18. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_charge_amount.py +0 -0
  19. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_compute.py +0 -0
  20. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_domains.py +0 -0
  21. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_emergency_error.py +0 -0
  22. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_max_cost_header.py +0 -0
  23. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_payment_rejection.py +0 -0
  24. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_phones_pending.py +0 -0
  25. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_physical_mail.py +0 -0
  26. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_poll_backoff.py +0 -0
  27. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_request_id.py +0 -0
  28. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_tag_receipt_value.py +0 -0
  29. {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_wait_false.py +0 -0
  30. {oneshot_python-0.23.1 → oneshot_python-0.24.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,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.23.1
3
+ Version: 0.24.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -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/) — 35 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.24.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:
@@ -505,11 +518,17 @@ class OneShotClient:
505
518
  wait: bool = True,
506
519
  wait_for_phones: bool = False,
507
520
  phone_timeout_sec: int = 360,
521
+ quote_id: Optional[str] = None,
508
522
  ) -> Any:
509
523
  """Execute a paid tool call (async). Handles the full x402 flow.
510
524
 
511
525
  With ``wait=False`` a queued job is returned as
512
526
  ``{"request_id": ..., "status": ...}`` instead of being polled.
527
+
528
+ Pass ``quote_id`` when the caller already obtained a quote for this
529
+ exact request (e.g. ``/email/send`` after ``/email/quote``) — it rides
530
+ on the ``x-quote-id`` header from the very first attempt, since
531
+ quote-based routes reject before the 402 dance even starts without it.
513
532
  """
514
533
  memo = payload.get("memo")
515
534
  if memo is not None:
@@ -540,8 +559,15 @@ class OneShotClient:
540
559
  await self.aensure_budgets_synced()
541
560
 
542
561
  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)
562
+ # Step 1 — Initial POST (expect 402 for paid tools). Quote-based
563
+ # routes (e.g. /email/send) reject with 400 quote_id_required
564
+ # before they ever get to the 402 dance unless x-quote-id rides
565
+ # on this very first attempt, so an already-obtained quote_id
566
+ # must be sent from the start, not just on the paid retry.
567
+ initial_headers = self._headers(max_cost=max_cost)
568
+ if quote_id:
569
+ initial_headers["x-quote-id"] = quote_id
570
+ resp = await client.post(url, headers=initial_headers, json=payload)
545
571
 
546
572
  # The spend-budget gate rejects at quote time (403) so nothing is
547
573
  # signed; surface it as the typed error.
@@ -597,7 +623,12 @@ class OneShotClient:
597
623
  quote_data = resp.json()
598
624
  payment_request = quote_data["payment_request"]
599
625
  context = quote_data.get("context", {})
600
- quote_id = context.get("quote_id")
626
+ # Self-quoting tools (voice/sms/browser/compute) mint a fresh
627
+ # quote_id in this same 402's context; quote-then-send tools
628
+ # (email/send) already had one supplied by the caller and don't
629
+ # get one echoed back here — keep it rather than clobbering it
630
+ # with None, or the paid retry below loses x-quote-id entirely.
631
+ quote_id = context.get("quote_id") or quote_id
601
632
 
602
633
  total = context.get("total") or context.get("pricing", {}).get("total")
603
634
  if max_cost is not None and total is not None:
@@ -732,7 +763,7 @@ class OneShotClient:
732
763
  ) -> Any:
733
764
  """POST to a free endpoint (async)."""
734
765
  url = f"{self.base_url}{endpoint}"
735
- async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
766
+ async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
736
767
  resp = await client.post(url, headers=self._read_headers(), json=payload or {})
737
768
  if not resp.is_success:
738
769
  raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
@@ -1052,6 +1083,10 @@ class OneShotClient:
1052
1083
  timeout_sec: int = 300,
1053
1084
  ) -> Any:
1054
1085
  """Run a browser automation task. Async."""
1086
+ if secrets is not None:
1087
+ raise ValueError("Browser secrets are unsupported; import cookies or storage_state into a profile")
1088
+ if max_steps is not None and (type(max_steps) is not int or not 25 <= max_steps <= 100):
1089
+ raise ValueError("max_steps must be an integer between 25 and 100")
1055
1090
  payload: dict[str, Any] = {"task": task}
1056
1091
  if output_schema is not None:
1057
1092
  payload["output_schema"] = output_schema
@@ -1061,21 +1096,53 @@ class OneShotClient:
1061
1096
  payload["allowed_domains"] = allowed_domains
1062
1097
  if profile_id is not None:
1063
1098
  payload["profile_id"] = profile_id
1064
- if secrets is not None:
1065
- payload["secrets"] = secrets
1066
1099
  if max_steps is not None:
1067
1100
  payload["max_steps"] = max_steps
1068
1101
  return await self.acall_tool(
1069
1102
  "/v1/tools/browser", payload, max_cost=max_cost, timeout_sec=timeout_sec,
1070
1103
  )
1071
1104
 
1072
- def create_browser_profile(self, name: str) -> dict:
1105
+ def create_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
1073
1106
  """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:
1107
+ if cookies is not None and storage_state is not None:
1108
+ raise ValueError("Provide cookies or storage_state, not both")
1109
+ payload = {"name": name}
1110
+ if cookies is not None:
1111
+ payload["cookies"] = cookies
1112
+ if storage_state is not None:
1113
+ payload["storage_state"] = storage_state
1114
+ return self.call_free_post("/v1/tools/browser/profiles", payload)
1115
+
1116
+ async def acreate_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
1077
1117
  """Create a persistent browser profile. Async."""
1078
- return await self.acall_free_post("/v1/tools/browser/profiles", {"name": name})
1118
+ if cookies is not None and storage_state is not None:
1119
+ raise ValueError("Provide cookies or storage_state, not both")
1120
+ payload = {"name": name}
1121
+ if cookies is not None:
1122
+ payload["cookies"] = cookies
1123
+ if storage_state is not None:
1124
+ payload["storage_state"] = storage_state
1125
+ return await self.acall_free_post("/v1/tools/browser/profiles", payload)
1126
+
1127
+ def start_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
1128
+ """Open login/2FA live view. Do not log the returned live_url."""
1129
+ return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
1130
+
1131
+ async def astart_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
1132
+ return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
1133
+
1134
+ def get_browser_profile_setup(self, profile_id: str) -> dict:
1135
+ return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
1136
+
1137
+ async def aget_browser_profile_setup(self, profile_id: str) -> dict:
1138
+ return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
1139
+
1140
+ def finish_browser_profile_setup(self, profile_id: str) -> dict:
1141
+ """Save after human login/2FA; inspect saved cookies before navigation."""
1142
+ return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
1143
+
1144
+ async def afinish_browser_profile_setup(self, profile_id: str) -> dict:
1145
+ return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
1079
1146
 
1080
1147
  def list_browser_profiles(self) -> list:
1081
1148
  """List all browser profiles. Blocking."""
@@ -1160,9 +1227,13 @@ class OneShotClient:
1160
1227
  ``mailbox_mode == 'mailbox'``, and read ``mailbox_provisioning_fee`` on
1161
1228
  the quote for the exact amount before paying.
1162
1229
  """
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),
1230
+ return asyncio.get_event_loop().run_until_complete(
1231
+ self.aemail(
1232
+ to, subject, body,
1233
+ from_domain=from_domain, from_mailbox=from_mailbox, from_name=from_name,
1234
+ reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
1235
+ **kwargs,
1236
+ )
1166
1237
  )
1167
1238
 
1168
1239
  async def aemail(
@@ -1186,12 +1257,53 @@ class OneShotClient:
1186
1257
 
1187
1258
  ``mailbox_mode`` ('relay' | 'mailbox', default 'relay') selects how a
1188
1259
  domain sends — see ``email()`` for details.
1260
+
1261
+ Quotes before sending — mirrors the TS SDK (``index.ts`` ``email()``).
1262
+ ``/v1/tools/email/send`` requires an ``x-quote-id`` from a prior
1263
+ ``/v1/tools/email/quote`` call and 400s without one, and rotation mode
1264
+ (both ``from_domain``/``from_mailbox`` omitted) is decided at quote
1265
+ time and locked server-side for the matching send — so quoting first
1266
+ is not optional, it's the only way rotation (or any send) works.
1189
1267
  """
1268
+ # max_cost/timeout_sec are transport options for acall_tool, not
1269
+ # send-body fields — pull them out of kwargs before they reach
1270
+ # _build_email_payload, or they'd ride along in the JSON body (which
1271
+ # the server's Zod schema silently drops) instead of driving the
1272
+ # X-Max-Cost-USDC header + local budget check and the poll timeout.
1273
+ max_cost = kwargs.pop("max_cost", None)
1274
+ timeout_sec = kwargs.pop("timeout_sec", 120)
1275
+
1276
+ # Quote payload mirrors the TS SDK's quote body: no from_name/extra
1277
+ # kwargs (attachments, idempotencyKey, etc. are send-only concerns).
1278
+ quote_payload = _build_email_payload(
1279
+ to, subject, body, from_domain, from_mailbox, None, {},
1280
+ reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
1281
+ )
1282
+ quote = await self.acall_tool(
1283
+ "/v1/tools/email/quote", quote_payload, max_cost=max_cost,
1284
+ )
1285
+ quote_id = quote.get("quote_id") if isinstance(quote, dict) else None
1286
+ quoted_from_address = quote.get("from_address") if isinstance(quote, dict) else None
1287
+
1288
+ send_payload = _build_email_payload(
1289
+ to, subject, body, from_domain, from_mailbox, from_name, kwargs,
1290
+ reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
1291
+ )
1292
+ # Replay the quote-locked/rotated address explicitly. The server
1293
+ # already replays it from the quote row when from_address is absent,
1294
+ # but sending it back is harmless and mirrors the TS SDK
1295
+ # (index.ts:425,431) so a future /send-without-quote revision keeps
1296
+ # working unchanged.
1297
+ resolved_from_address = send_payload.get("from_address") or quoted_from_address
1298
+ if resolved_from_address:
1299
+ send_payload["from_address"] = resolved_from_address
1300
+
1190
1301
  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),
1302
+ "/v1/tools/email/send", send_payload, quote_id=quote_id,
1303
+ max_cost=max_cost, timeout_sec=timeout_sec,
1193
1304
  )
1194
1305
 
1306
+
1195
1307
  def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
1196
1308
  """Make an AI voice call. Blocking."""
1197
1309
  return self.call_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
@@ -1556,6 +1668,7 @@ class OneShotClient:
1556
1668
  f"Job failed: {job.get('error', 'Unknown')}",
1557
1669
  request_id,
1558
1670
  str(job.get("error", "Unknown")),
1671
+ code=job.get("error_code"), partial_result=job.get("result"),
1559
1672
  )
1560
1673
 
1561
1674
  retries = 0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.23.1"
3
+ version = "0.24.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -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
+
@@ -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")