oneshot-python 0.35.0__tar.gz → 0.37.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 (40) hide show
  1. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/PKG-INFO +2 -2
  2. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/README.md +25 -2
  3. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/oneshot/client.py +78 -3
  4. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/pyproject.toml +2 -2
  5. oneshot_python-0.37.0/tests/test_compute_outputs.py +47 -0
  6. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_domains.py +3 -3
  7. oneshot_python-0.37.0/tests/test_domains_signed.py +78 -0
  8. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_linkedin.py +34 -0
  9. oneshot_python-0.37.0/tests/test_paid_call_proof.py +101 -0
  10. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/.gitignore +0 -0
  11. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/oneshot/__init__.py +0 -0
  12. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/oneshot/_errors.py +0 -0
  13. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/oneshot/_types.py +0 -0
  14. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/oneshot/physical_mail.py +0 -0
  15. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/oneshot/x402.py +0 -0
  16. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/__init__.py +0 -0
  17. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/conftest.py +0 -0
  18. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_access_token.py +0 -0
  19. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_approvals.py +0 -0
  20. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_balance.py +0 -0
  21. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_browser.py +0 -0
  22. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_budgets.py +0 -0
  23. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_charge_amount.py +0 -0
  24. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_compute.py +0 -0
  25. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_email_payload.py +0 -0
  26. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_emergency_error.py +0 -0
  27. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_hunts.py +0 -0
  28. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_inbound_voice.py +0 -0
  29. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_max_cost_header.py +0 -0
  30. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_payment_rejection.py +0 -0
  31. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_phones_pending.py +0 -0
  32. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_physical_mail.py +0 -0
  33. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_poll_backoff.py +0 -0
  34. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_request_id.py +0 -0
  35. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_signed_job_and_analytics.py +0 -0
  36. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_tag_receipt_value.py +0 -0
  37. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_top_up_credits.py +0 -0
  38. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_wait_false.py +0 -0
  39. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/tests/test_x402.py +0 -0
  40. {oneshot_python-0.35.0 → oneshot_python-0.37.0}/uv.lock +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.35.0
4
- Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 45 tools
3
+ Version: 0.37.0
4
+ Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 48 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: eth-account>=0.13.0
@@ -194,6 +194,7 @@ print(goal["goal_id"])
194
194
  # Observe
195
195
  status = client.get_compute_goal(goal["goal_id"])
196
196
  tasks = client.get_compute_tasks(goal["goal_id"])
197
+ outputs = client.get_compute_outputs(goal["goal_id"]) # what the tasks produced; read one with get_compute_output(goal_id, key)
197
198
  budget = client.get_compute_budget(goal["goal_id"])
198
199
 
199
200
  # Top up a recurring goal (paid)
@@ -212,7 +213,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
212
213
 
213
214
  ### Physical mail
214
215
 
215
- `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 a second charge. Postal delivery does not prove the letter was read; paid receipts and fulfillment events are separate. Lob credentials stay on OneShot's server.
216
+ `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 a second charge. Postal delivery does not prove the letter was read; paid receipts and fulfillment events are separate. Mail provider credentials stay on OneShot's server.
216
217
 
217
218
  ### Account login and 2FA (0.24.0+)
218
219
 
@@ -269,6 +270,28 @@ client.linkedin_withdraw_invitation(
269
270
 
270
271
  Async equivalents are `alinkedin_invite()` and `alinkedin_withdraw_invitation()`. Use the OneShot `invitation_id`, not the provider invitation ID. Withdrawal can cancel a queued invitation and never disconnects an established connection. Invitations default to 25 per account per UTC day; withdrawal does not restore capacity for an invitation already sent. Reuse the same key when retrying an operation.
271
272
 
273
+ ### LinkedIn: message a connection and read invitation status
274
+
275
+ Available in `oneshot-python` 0.36.0 and later.
276
+
277
+ ```python
278
+ # Accepted as soon as the person is a connection; no message needs to be exchanged first.
279
+ current = client.linkedin_invitation(account_id, invitation["invitation_id"])
280
+ if current["status"] == "accepted":
281
+ sent = client.linkedin_message(
282
+ account_id,
283
+ "https://www.linkedin.com/in/jane-doe/",
284
+ "Thanks for connecting.",
285
+ idempotency_key="event-jane-first-message",
286
+ )
287
+ conversation_id = sent["conversation_id"]
288
+
289
+ # Poll for changes instead of asking one by one:
290
+ changed = client.linkedin_invitations(account_id, status="accepted", changed_since="2026-10-01T00:00:00Z")
291
+ ```
292
+
293
+ `linkedin_message()` posts into the 1:1 conversation the account already has with that connection, or starts one. It needs the `reply` grant and shares the reply daily cap. A failed send carries an error code: `not_connected` (invite them first) and `rate_limited` mean defer, not give up. The two invitation reads are free. Statuses are `pending`, `accepted`, `withdrawn`, `declined_or_unknown` and `not_sent`. Async equivalents are `alinkedin_message()`, `alinkedin_invitation()` and `alinkedin_invitations()`.
294
+
272
295
  ### Soul Hunt
273
296
 
274
297
  `client.hunts` talks to Soul Hunt (`api.soulhunt.ai` by default — override with `OneShotClient(..., hunt_url=...)`), a separate service from the main OneShot API:
@@ -301,7 +324,7 @@ Async variants prefix the method with `a` (`alist`, `astatus`, `apredict`, `acap
301
324
  ## Links
302
325
 
303
326
  - [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
304
- - [LangChain integration](https://pypi.org/project/langchain-oneshot/): 45 tools as LangChain BaseTool
327
+ - [LangChain integration](https://pypi.org/project/langchain-oneshot/): 48 tools as LangChain BaseTool
305
328
  - [GAME plugin](https://pypi.org/project/game-plugin-oneshot/): Virtuals Protocol integration
306
329
  - [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
307
330
  - [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
@@ -59,11 +59,13 @@ try:
59
59
 
60
60
  SDK_VERSION = _pkg_version("oneshot-python")
61
61
  except Exception: # pragma: no cover - editable/source runs without dist metadata
62
- SDK_VERSION = "0.35.0"
62
+ SDK_VERSION = "0.37.0"
63
63
 
64
64
  _BASE_URL = "https://win.oneshotagent.com"
65
65
  _HUNT_BASE_URL = "https://api.soulhunt.ai"
66
66
  _ACCESS_TOKEN_RE = re.compile(r"^oneshot_[0-9a-f]{64}$")
67
+ # Endpoints whose admission reads a "submit"-scoped agent proof (same list as the TypeScript SDK).
68
+ _DURABLE_ADMISSION_ENDPOINT = re.compile(r"(enrich/(profile|email)|verify/email|physical-mail/send)$")
67
69
  _CHAIN_ID = 8453
68
70
  _USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
69
71
 
@@ -631,6 +633,24 @@ class OneShotClient:
631
633
  self._log(f"Failed to sign read proof (continuing without): {e}")
632
634
  return headers
633
635
 
636
+ def _prove_wallet(self, headers: dict[str, str], endpoint: str) -> dict[str, str]:
637
+ """Add a signed agent proof to a paid-call leg that carries no signed
638
+ payment. The API spends an agent's credits, and shows them on a quote,
639
+ only for a caller that has proven it holds the wallet: ``X-Agent-ID``
640
+ alone is a public address. A leg with a real payment signature needs
641
+ none. The endpoints with durable admission read a ``submit`` proof.
642
+ An access-token session is already bound to its agent. A signing
643
+ failure is logged and the leg goes without, so the call is priced as
644
+ if the agent held no credits."""
645
+ if self._access_token:
646
+ return headers
647
+ scope = "submit" if _DURABLE_ADMISSION_ENDPOINT.search(endpoint) else "write"
648
+ try:
649
+ headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope=scope)
650
+ except Exception as e: # noqa: BLE001
651
+ self._log(f"Failed to sign agent proof (continuing without; credits will not be used): {e}")
652
+ return headers
653
+
634
654
  def _write_headers(self, domain_name: str = "OneShot Agent Auth") -> dict[str, str]:
635
655
  """Like ``_read_headers`` but with a ``write``-scoped proof, for the
636
656
  signed mutation routes (budgets). The server enforces the proof on
@@ -851,6 +871,7 @@ class OneShotClient:
851
871
  # on this very first attempt, so an already-obtained quote_id
852
872
  # must be sent from the start, not just on the paid retry.
853
873
  initial_headers = self._with_approval(self._headers(max_cost=max_cost, idempotency_key=idempotency_key), approval_id)
874
+ self._prove_wallet(initial_headers, endpoint)
854
875
  if quote_id:
855
876
  initial_headers["x-quote-id"] = quote_id
856
877
  resp = await client.post(url, headers=initial_headers, json=payload)
@@ -985,6 +1006,9 @@ class OneShotClient:
985
1006
  **self._with_approval(self._headers(max_cost=max_cost, idempotency_key=idempotency_key), approval_id),
986
1007
  "payment-signature": encode_payment_header(auth),
987
1008
  }
1009
+ # The zero-cost authorization is an unsigned stub, so this leg
1010
+ # still has to prove the wallet for the credits to be spent.
1011
+ self._prove_wallet(headers, endpoint)
988
1012
  if quote_id:
989
1013
  headers["x-quote-id"] = quote_id
990
1014
  resp2 = await client.post(url, headers=headers, json=payload)
@@ -1368,6 +1392,32 @@ class OneShotClient:
1368
1392
  """List tasks under a goal. Async."""
1369
1393
  return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/tasks"))
1370
1394
 
1395
+ def get_compute_outputs(self, goal_id: str) -> Any:
1396
+ """List what a goal produced: the files its sandbox tasks left and the
1397
+ tool results too large for a task row. The goal's short answer is
1398
+ ``result`` on ``get_compute_goal``. Blocking."""
1399
+ return asyncio.get_event_loop().run_until_complete(
1400
+ self.aget_compute_outputs(goal_id)
1401
+ )
1402
+
1403
+ async def aget_compute_outputs(self, goal_id: str) -> Any:
1404
+ """List what a goal produced. Async."""
1405
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/outputs"))
1406
+
1407
+ def get_compute_output(self, goal_id: str, key: str) -> Any:
1408
+ """Read one output of a goal by the ``key`` from ``get_compute_outputs``
1409
+ (or a task's ``outputs``). ``content`` is None when the output was too
1410
+ large to keep (``readable`` False). Blocking."""
1411
+ return asyncio.get_event_loop().run_until_complete(
1412
+ self.aget_compute_output(goal_id, key)
1413
+ )
1414
+
1415
+ async def aget_compute_output(self, goal_id: str, key: str) -> Any:
1416
+ """Read one output of a goal. Async."""
1417
+ # The key is a path: keep its slashes, escape everything else.
1418
+ path = "/".join(quote(part, safe="") for part in key.split("/"))
1419
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/outputs/{path}"))
1420
+
1371
1421
  def get_compute_budget(self, goal_id: str) -> Any:
1372
1422
  """Get budget breakdown. Blocking."""
1373
1423
  return asyncio.get_event_loop().run_until_complete(
@@ -1667,6 +1717,20 @@ class OneShotClient:
1667
1717
  async def alinkedin_messages(self, account_id: str, **params: Any) -> dict:
1668
1718
  return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/messages", _linkedin_query(params))
1669
1719
 
1720
+ def linkedin_invitation(self, account_id: str, invitation_id: str) -> dict:
1721
+ """One invitation sent through linkedin_invite: `accepted` (with `accepted_at`) as soon as the person is a connection. Free."""
1722
+ return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/invitations/{quote(invitation_id, safe='')}")
1723
+
1724
+ async def alinkedin_invitation(self, account_id: str, invitation_id: str) -> dict:
1725
+ return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/invitations/{quote(invitation_id, safe='')}")
1726
+
1727
+ def linkedin_invitations(self, account_id: str, **params: Any) -> dict:
1728
+ """Invitations sent through linkedin_invite, most recently changed first. Params: status, changed_since, limit. Free."""
1729
+ return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/invitations", _linkedin_query(params))
1730
+
1731
+ async def alinkedin_invitations(self, account_id: str, **params: Any) -> dict:
1732
+ return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/invitations", _linkedin_query(params))
1733
+
1670
1734
  def _linkedin_invitation_call(self, endpoint: str, payload: dict, **kwargs: Any) -> Any:
1671
1735
  return asyncio.get_event_loop().run_until_complete(self._alinkedin_invitation_call(endpoint, payload, **kwargs))
1672
1736
 
@@ -1713,6 +1777,17 @@ class OneShotClient:
1713
1777
  return await self.acall_tool("/v1/tools/linkedin/reply", {"account_id": account_id, "conversation_id": conversation_id, "text": text, **kwargs},
1714
1778
  max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1715
1779
 
1780
+ def linkedin_message(self, account_id: str, profile: str, text: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1781
+ """Message a first-degree connection: into the 1:1 conversation the account already has with them, or a new one.
1782
+ Needs the `reply` grant and shares its daily cap. A failed send carries an error code: `not_connected` and
1783
+ `rate_limited` mean defer, not give up. An Idempotency-Key is generated when not supplied. Blocking."""
1784
+ return self.call_tool("/v1/tools/linkedin/message", {"account_id": account_id, "profile": profile, "text": text, **kwargs},
1785
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1786
+
1787
+ async def alinkedin_message(self, account_id: str, profile: str, text: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1788
+ return await self.acall_tool("/v1/tools/linkedin/message", {"account_id": account_id, "profile": profile, "text": text, **kwargs},
1789
+ max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
1790
+
1716
1791
  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:
1717
1792
  """View a profile through the connected account (public-safe summary). notify=True cannot be undone. Blocking."""
1718
1793
  return self.call_tool("/v1/tools/linkedin/profile-view", {"account_id": account_id, "identifier": identifier, "notify": bool(notify), **kwargs},
@@ -2093,7 +2168,7 @@ class OneShotClient:
2093
2168
 
2094
2169
  async def apause_domain(self, domain: str) -> DomainPoolStatusResult:
2095
2170
  """Take a domain out of rotation without releasing it. Async."""
2096
- return await self.acall_free_post(self._domain_action_path(domain, "pause"))
2171
+ return await self.acall_free_post(self._domain_action_path(domain, "pause"), scope="write")
2097
2172
 
2098
2173
  def resume_domain(self, domain: str) -> DomainPoolStatusResult:
2099
2174
  """Put a paused domain back into rotation. Blocking."""
@@ -2105,7 +2180,7 @@ class OneShotClient:
2105
2180
  Reputation pauses (`pause_reason='low_reputation'`) auto-recover once the
2106
2181
  domain is warmed again; a manual pause needs this call.
2107
2182
  """
2108
- return await self.acall_free_post(self._domain_action_path(domain, "resume"))
2183
+ return await self.acall_free_post(self._domain_action_path(domain, "resume"), scope="write")
2109
2184
 
2110
2185
  @staticmethod
2111
2186
  def _domain_action_path(domain: str, action: str) -> str:
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.35.0"
4
- description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 45 tools"
3
+ version = "0.37.0"
4
+ description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 48 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,47 @@
1
+ """Reading what a compute goal produced. Mirrors tests/unit/sdk-compute-outputs.test.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, patch
6
+
7
+ import pytest
8
+
9
+ from oneshot.client import OneShotClient
10
+
11
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
12
+ GOAL = "b33839af-380f-4880-8355-57629ec68cb3"
13
+ KEY = "artifacts/5b36dba3-7978-4bab-b222-c003455c1bcf/result.txt"
14
+ OUTPUT = {"key": KEY, "task_id": "5b36dba3-7978-4bab-b222-c003455c1bcf", "content_type": "text/plain",
15
+ "size_bytes": 84, "summary": "Agent output", "readable": True, "updated_at": "2026-10-12T09:54:52.000Z"}
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_lists_outputs():
20
+ client = OneShotClient(TEST_PRIVATE_KEY)
21
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": [OUTPUT]})) as get:
22
+ assert await client.aget_compute_outputs(GOAL) == [OUTPUT]
23
+ get.assert_awaited_once_with(f"/v1/compute/{GOAL}/outputs")
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_reads_one_output_keeping_the_slashes_of_its_key():
28
+ client = OneShotClient(TEST_PRIVATE_KEY)
29
+ body = {**OUTPUT, "content": "f6a0\n2880067194370816120", "truncated": False}
30
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": body})) as get:
31
+ out = await client.aget_compute_output(GOAL, KEY)
32
+ assert out["content"] == "f6a0\n2880067194370816120"
33
+ get.assert_awaited_once_with(f"/v1/compute/{GOAL}/outputs/{KEY}")
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_escapes_each_segment_of_a_key():
38
+ client = OneShotClient(TEST_PRIVATE_KEY)
39
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": {}})) as get:
40
+ await client.aget_compute_output(GOAL, "artifacts/task/my report #1.txt")
41
+ get.assert_awaited_once_with(f"/v1/compute/{GOAL}/outputs/artifacts/task/my%20report%20%231.txt")
42
+
43
+
44
+ def test_blocking_wrappers_exist():
45
+ client = OneShotClient(TEST_PRIVATE_KEY)
46
+ with patch.object(client, "acall_free_get", new=AsyncMock(return_value={"success": True, "data": [OUTPUT]})):
47
+ assert client.get_compute_outputs(GOAL) == [OUTPUT]
@@ -148,7 +148,7 @@ class TestPauseResumeDomain:
148
148
  ) as mock:
149
149
  result = await client.apause_domain("warmed-example.com")
150
150
 
151
- mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/pause")
151
+ mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/pause", scope="write")
152
152
  assert result == MOCK_STATUS_RESPONSE
153
153
 
154
154
  @pytest.mark.asyncio
@@ -160,7 +160,7 @@ class TestPauseResumeDomain:
160
160
  ) as mock:
161
161
  await client.aresume_domain("warmed-example.com")
162
162
 
163
- mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/resume")
163
+ mock.assert_called_once_with("/v1/tools/email/domains/warmed-example.com/resume", scope="write")
164
164
 
165
165
  @pytest.mark.asyncio
166
166
  async def test_encodes_the_domain_segment(self) -> None:
@@ -173,7 +173,7 @@ class TestPauseResumeDomain:
173
173
  await client.apause_domain("evil.com/../../admin")
174
174
 
175
175
  mock.assert_called_once_with(
176
- "/v1/tools/email/domains/evil.com%2F..%2F..%2Fadmin/pause"
176
+ "/v1/tools/email/domains/evil.com%2F..%2F..%2Fadmin/pause", scope="write"
177
177
  )
178
178
 
179
179
  @pytest.mark.asyncio
@@ -0,0 +1,78 @@
1
+ """The email-domain routes are signed by this SDK (#1399).
2
+
3
+ The API answers an unsigned caller 401 ``proof_required`` on GET
4
+ /v1/tools/email/domains (read proof) and POST .../pause and .../resume (write
5
+ proof). These tests decode the real ``x-agent-proof`` header the client sends,
6
+ not just that one is present.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ from unittest.mock import AsyncMock, MagicMock, patch
14
+
15
+ import pytest
16
+
17
+ from oneshot.client import OneShotClient
18
+
19
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
20
+
21
+
22
+ def _proof(headers: dict[str, str]) -> dict:
23
+ assert "x-agent-proof" in headers, f"no x-agent-proof in {sorted(headers)}"
24
+ return json.loads(base64.b64decode(headers["x-agent-proof"]))
25
+
26
+
27
+ def _http(method: str) -> MagicMock:
28
+ resp = MagicMock()
29
+ resp.is_success = True
30
+ resp.status_code = 200
31
+ resp.json.return_value = {"ok": True}
32
+ http = MagicMock()
33
+ setattr(http, method, AsyncMock(return_value=resp))
34
+ http.__aenter__ = AsyncMock(return_value=http)
35
+ http.__aexit__ = AsyncMock(return_value=None)
36
+ return http
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_list_domains_sends_a_read_proof() -> None:
41
+ client = OneShotClient(TEST_PRIVATE_KEY)
42
+ http = _http("get")
43
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
44
+ await client.alist_domains()
45
+
46
+ call = http.get.call_args
47
+ assert call.args[0].endswith("/v1/tools/email/domains")
48
+ proof = _proof(call.kwargs["headers"])
49
+ assert proof["scope"] == "read"
50
+ assert proof["agent"] == client.address
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ @pytest.mark.parametrize("action", ["pause", "resume"])
55
+ async def test_pause_and_resume_send_a_write_proof(action: str) -> None:
56
+ client = OneShotClient(TEST_PRIVATE_KEY)
57
+ http = _http("post")
58
+ method = client.apause_domain if action == "pause" else client.aresume_domain
59
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
60
+ await method("warmed-example.com")
61
+
62
+ call = http.post.call_args
63
+ assert call.args[0].endswith(f"/v1/tools/email/domains/warmed-example.com/{action}")
64
+ proof = _proof(call.kwargs["headers"])
65
+ assert proof["scope"] == "write"
66
+ assert proof["agent"] == client.address
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_each_domain_call_signs_a_fresh_nonce() -> None:
71
+ client = OneShotClient(TEST_PRIVATE_KEY)
72
+ http = _http("post")
73
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
74
+ await client.apause_domain("a.com")
75
+ await client.apause_domain("a.com")
76
+
77
+ nonces = [_proof(c.kwargs["headers"])["nonce"] for c in http.post.call_args_list]
78
+ assert len(set(nonces)) == 2
@@ -187,3 +187,37 @@ def test_reconnect_sends_complete_upgrade_grant(asynchronous):
187
187
  client.call_free_post = Mock(return_value=result)
188
188
  assert client.reconnect_linkedin_account('account/id', requested_actions=actions) == result
189
189
  client.call_free_post.assert_called_once_with('/v1/tools/linkedin/accounts/account%2Fid/reconnect', {'requested_actions': actions}, scope='write')
190
+
191
+
192
+ @pytest.mark.asyncio
193
+ async def test_message_is_paid_and_idempotency_keyed():
194
+ client = OneShotClient(TEST_PRIVATE_KEY)
195
+ client.acall_tool = AsyncMock(return_value={"status": "sent", "conversation_id": "conv"})
196
+
197
+ await client.alinkedin_message("acc", "https://www.linkedin.com/in/jane/", "hi", memo="m")
198
+ args, kwargs = client.acall_tool.await_args
199
+ assert args == ("/v1/tools/linkedin/message", {"account_id": "acc", "profile": "https://www.linkedin.com/in/jane/", "text": "hi", "memo": "m"})
200
+ assert len(kwargs["idempotency_key"]) == 32 # auto-generated
201
+ assert kwargs["timeout_sec"] == 180 and kwargs["wait"] is True
202
+
203
+ await client.alinkedin_message("acc", "jane", "hi", idempotency_key="fixed")
204
+ assert client.acall_tool.await_args.kwargs["idempotency_key"] == "fixed"
205
+
206
+
207
+ @pytest.mark.asyncio
208
+ async def test_invitation_status_reads_are_free():
209
+ client = OneShotClient(TEST_PRIVATE_KEY)
210
+ client.acall_free_get = AsyncMock(return_value={"invitations": []})
211
+ client.acall_tool = AsyncMock()
212
+
213
+ await client.alinkedin_invitation("acc", "inv 1")
214
+ client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/accounts/acc/invitations/inv%201")
215
+ await client.alinkedin_invitations("acc", status="accepted", changed_since="2026-10-01T00:00:00Z", limit=20)
216
+ client.acall_free_get.assert_awaited_with(
217
+ "/v1/tools/linkedin/accounts/acc/invitations",
218
+ {"status": "accepted", "changed_since": "2026-10-01T00:00:00Z", "limit": "20"},
219
+ )
220
+ await client.alinkedin_invitations("acc")
221
+ client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/accounts/acc/invitations", None)
222
+ client.acall_tool.assert_not_awaited()
223
+
@@ -0,0 +1,101 @@
1
+ """The SDK proves the wallet on the legs of a paid call that carry no signed payment.
2
+
3
+ The API spends an agent's credits, and shows them on a quote, only for a caller
4
+ that has proven it holds the wallet: ``X-Agent-ID`` alone is a public address.
5
+ Mirrors tests/unit/sdk-paid-call-proof.test.ts on the TypeScript side.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ from unittest.mock import AsyncMock, MagicMock, patch
13
+
14
+ import pytest
15
+ from eth_account import Account
16
+ from eth_account.messages import encode_typed_data
17
+
18
+ from oneshot.client import OneShotClient
19
+
20
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
21
+ ACCESS_TOKEN = "oneshot_" + "a" * 64
22
+ WALLET = "0x" + "22" * 20
23
+
24
+
25
+ def _resp(status_code: int, payload: dict) -> MagicMock:
26
+ resp = MagicMock()
27
+ resp.status_code = status_code
28
+ resp.is_success = 200 <= status_code < 300
29
+ resp.json.return_value = payload
30
+ resp.text = ""
31
+ resp.headers = {}
32
+ return resp
33
+
34
+
35
+ def _proof(headers: dict) -> dict:
36
+ """Decode an x-agent-proof header and recover who signed it."""
37
+ proof = json.loads(base64.b64decode(headers["x-agent-proof"]))
38
+ signable = encode_typed_data(
39
+ domain_data={"name": "OneShot Agent Auth", "version": "1"},
40
+ message_types={"AgentReadAuth": [
41
+ {"name": "agent", "type": "address"}, {"name": "scope", "type": "string"},
42
+ {"name": "issuedAt", "type": "uint256"}, {"name": "nonce", "type": "bytes32"},
43
+ ]},
44
+ message_data={"agent": proof["agent"], "scope": proof["scope"], "issuedAt": proof["issuedAt"],
45
+ "nonce": bytes.fromhex(proof["nonce"][2:])},
46
+ )
47
+ proof["signer"] = Account.recover_message(signable, signature=proof["signature"])
48
+ return proof
49
+
50
+
51
+ async def _first_leg_headers(client: OneShotClient, endpoint: str) -> dict:
52
+ """Run one paid call that the API answers at once, and return the headers of its first request."""
53
+ client._budget_synced = True
54
+ http = MagicMock()
55
+ http.post = AsyncMock(return_value=_resp(200, {"success": True, "data": {"status": "completed"}}))
56
+ http.__aenter__ = AsyncMock(return_value=http)
57
+ http.__aexit__ = AsyncMock(return_value=False)
58
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
59
+ await client.acall_tool(endpoint, {"query": "x", "memo": "test"})
60
+ return http.post.await_args_list[0].kwargs["headers"]
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_first_leg_carries_a_write_proof_signed_by_the_wallet():
65
+ client = OneShotClient(TEST_PRIVATE_KEY)
66
+ headers = await _first_leg_headers(client, "/v1/tools/search")
67
+ assert "payment-signature" not in headers
68
+ proof = _proof(headers)
69
+ assert proof["scope"] == "write"
70
+ assert proof["agent"] == client.address
71
+ assert proof["signer"] == client.address
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_each_call_signs_a_fresh_proof():
76
+ client = OneShotClient(TEST_PRIVATE_KEY)
77
+ first = _proof(await _first_leg_headers(client, "/v1/tools/search"))
78
+ second = _proof(await _first_leg_headers(client, "/v1/tools/search"))
79
+ assert first["nonce"] != second["nonce"]
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ @pytest.mark.parametrize("endpoint", ["/v1/tools/enrich/profile", "/v1/tools/enrich/email", "/v1/tools/verify/email"])
84
+ async def test_durable_admission_endpoints_get_the_submit_proof_they_read(endpoint):
85
+ client = OneShotClient(TEST_PRIVATE_KEY)
86
+ assert _proof(await _first_leg_headers(client, endpoint))["scope"] == "submit"
87
+
88
+
89
+ def test_access_token_session_sends_no_proof():
90
+ client = OneShotClient(access_token=ACCESS_TOKEN, address=WALLET)
91
+ headers = client._prove_wallet(client._headers(), "/v1/tools/search")
92
+ assert headers["Authorization"] == f"Bearer {ACCESS_TOKEN}"
93
+ assert "x-agent-proof" not in headers
94
+
95
+
96
+ def test_a_signing_failure_sends_the_leg_without_a_proof():
97
+ client = OneShotClient(TEST_PRIVATE_KEY)
98
+ with patch("oneshot.client.sign_read_proof", side_effect=RuntimeError("no signer")):
99
+ headers = client._prove_wallet(client._headers(), "/v1/tools/search")
100
+ assert "x-agent-proof" not in headers
101
+ assert headers["X-Agent-ID"] == client.address
File without changes