oneshot-python 0.24.0__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 (30) hide show
  1. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/PKG-INFO +2 -2
  2. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/README.md +1 -1
  3. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/client.py +167 -16
  4. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/pyproject.toml +2 -2
  5. oneshot_python-0.25.0/tests/test_linkedin.py +72 -0
  6. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/.gitignore +0 -0
  7. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/__init__.py +0 -0
  8. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/_errors.py +0 -0
  9. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/_types.py +0 -0
  10. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/physical_mail.py +0 -0
  11. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/x402.py +0 -0
  12. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/__init__.py +0 -0
  13. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_balance.py +0 -0
  14. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_browser.py +0 -0
  15. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_budgets.py +0 -0
  16. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_charge_amount.py +0 -0
  17. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_compute.py +0 -0
  18. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_domains.py +0 -0
  19. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_email_payload.py +0 -0
  20. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_emergency_error.py +0 -0
  21. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_max_cost_header.py +0 -0
  22. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_payment_rejection.py +0 -0
  23. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_phones_pending.py +0 -0
  24. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_physical_mail.py +0 -0
  25. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_poll_backoff.py +0 -0
  26. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_request_id.py +0 -0
  27. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_tag_receipt_value.py +0 -0
  28. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_wait_false.py +0 -0
  29. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_x402.py +0 -0
  30. {oneshot_python-0.24.0 → oneshot_python-0.25.0}/uv.lock +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.24.0
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/) — 35 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)
@@ -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.24.0"
54
+ SDK_VERSION = "0.25.0"
55
55
 
56
56
  # ---------------------------------------------------------------------------
57
57
  # Environment configuration
@@ -312,12 +312,16 @@ class OneShotClient:
312
312
  # Headers
313
313
  # ------------------------------------------------------------------
314
314
 
315
- 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]:
316
316
  headers = {
317
317
  "Content-Type": "application/json",
318
318
  "X-Agent-ID": self.address,
319
319
  "X-OneShot-SDK-Version": SDK_VERSION,
320
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
321
325
  # Server-side budget cap. The API rejects with HTTP 400
322
326
  # `exceeds_caller_budget` before requesting payment if the quoted
323
327
  # total exceeds this cap. The local fast-fail below stays as defense
@@ -331,16 +335,17 @@ class OneShotClient:
331
335
  from oneshot.physical_mail import PhysicalMail
332
336
  return PhysicalMail(self)
333
337
 
334
- def _read_headers(self) -> dict[str, str]:
338
+ def _read_headers(self, scope: str = "read") -> dict[str, str]:
335
339
  """Auth headers plus a signed EIP-712 read proof (x-agent-proof) for the
336
340
  free READ routes (inbox, sms inbox, notifications, balance, browser
337
341
  profiles). These identify the caller by wallet, and wallet addresses are
338
342
  public, so without a proof anyone could read another agent's data by
339
343
  supplying its address. Signing failure falls back to plain headers (the
340
- 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"."""
341
346
  headers = self._headers()
342
347
  try:
343
- 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)
344
349
  except Exception as e: # noqa: BLE001
345
350
  self._log(f"Failed to sign read proof (continuing without): {e}")
346
351
  return headers
@@ -446,6 +451,7 @@ class OneShotClient:
446
451
  wait: bool = True,
447
452
  wait_for_phones: bool = False,
448
453
  phone_timeout_sec: int = 360,
454
+ idempotency_key: Optional[str] = None,
449
455
  ) -> Any:
450
456
  """Execute a paid tool call (blocking). Handles the full x402 flow.
451
457
 
@@ -467,6 +473,7 @@ class OneShotClient:
467
473
  wait=wait,
468
474
  wait_for_phones=wait_for_phones,
469
475
  phone_timeout_sec=phone_timeout_sec,
476
+ idempotency_key=idempotency_key,
470
477
  )
471
478
  )
472
479
 
@@ -519,6 +526,7 @@ class OneShotClient:
519
526
  wait_for_phones: bool = False,
520
527
  phone_timeout_sec: int = 360,
521
528
  quote_id: Optional[str] = None,
529
+ idempotency_key: Optional[str] = None,
522
530
  ) -> Any:
523
531
  """Execute a paid tool call (async). Handles the full x402 flow.
524
532
 
@@ -564,7 +572,7 @@ class OneShotClient:
564
572
  # before they ever get to the 402 dance unless x-quote-id rides
565
573
  # on this very first attempt, so an already-obtained quote_id
566
574
  # must be sent from the start, not just on the paid retry.
567
- initial_headers = self._headers(max_cost=max_cost)
575
+ initial_headers = self._headers(max_cost=max_cost, idempotency_key=idempotency_key)
568
576
  if quote_id:
569
577
  initial_headers["x-quote-id"] = quote_id
570
578
  resp = await client.post(url, headers=initial_headers, json=payload)
@@ -653,7 +661,7 @@ class OneShotClient:
653
661
  extensions=parsed_req.get("extensions"),
654
662
  )
655
663
  headers = {
656
- **self._headers(max_cost=max_cost),
664
+ **self._headers(max_cost=max_cost, idempotency_key=idempotency_key),
657
665
  "payment-signature": encode_payment_header(auth),
658
666
  }
659
667
  if quote_id:
@@ -675,7 +683,7 @@ class OneShotClient:
675
683
 
676
684
  # Step 4 — Re-POST with payment headers (x402 format)
677
685
  headers = {
678
- **self._headers(max_cost=max_cost),
686
+ **self._headers(max_cost=max_cost, idempotency_key=idempotency_key),
679
687
  "payment-signature": encode_payment_header(auth),
680
688
  }
681
689
  if quote_id:
@@ -750,21 +758,25 @@ class OneShotClient:
750
758
  self,
751
759
  endpoint: str,
752
760
  payload: Optional[dict[str, Any]] = None,
761
+ *,
762
+ scope: str = "read",
753
763
  ) -> Any:
754
- """POST to a free endpoint (blocking)."""
764
+ """POST to a free endpoint (blocking). Mutations pass scope="write"."""
755
765
  return asyncio.get_event_loop().run_until_complete(
756
- self.acall_free_post(endpoint, payload)
766
+ self.acall_free_post(endpoint, payload, scope=scope)
757
767
  )
758
768
 
759
769
  async def acall_free_post(
760
770
  self,
761
771
  endpoint: str,
762
772
  payload: Optional[dict[str, Any]] = None,
773
+ *,
774
+ scope: str = "read",
763
775
  ) -> Any:
764
- """POST to a free endpoint (async)."""
776
+ """POST to a free endpoint (async). Mutations pass scope="write"."""
765
777
  url = f"{self.base_url}{endpoint}"
766
778
  async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
767
- resp = await client.post(url, headers=self._read_headers(), json=payload or {})
779
+ resp = await client.post(url, headers=self._read_headers(scope), json=payload or {})
768
780
  if not resp.is_success:
769
781
  raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
770
782
  return resp.json()
@@ -821,20 +833,24 @@ class OneShotClient:
821
833
  def call_free_delete(
822
834
  self,
823
835
  endpoint: str,
836
+ *,
837
+ scope: str = "read",
824
838
  ) -> Any:
825
- """DELETE a free endpoint (blocking)."""
839
+ """DELETE a free endpoint (blocking). Mutations pass scope="write"."""
826
840
  return asyncio.get_event_loop().run_until_complete(
827
- self.acall_free_delete(endpoint)
841
+ self.acall_free_delete(endpoint, scope=scope)
828
842
  )
829
843
 
830
844
  async def acall_free_delete(
831
845
  self,
832
846
  endpoint: str,
847
+ *,
848
+ scope: str = "read",
833
849
  ) -> Any:
834
- """DELETE a free endpoint (async)."""
850
+ """DELETE a free endpoint (async). Mutations pass scope="write"."""
835
851
  url = f"{self.base_url}{endpoint}"
836
852
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
837
- resp = await client.delete(url, headers=self._read_headers())
853
+ resp = await client.delete(url, headers=self._read_headers(scope))
838
854
  if not resp.is_success:
839
855
  raise ToolError(f"DELETE {endpoint} failed", resp.status_code, resp.text)
840
856
  if resp.status_code == 204 or not resp.text:
@@ -1160,6 +1176,107 @@ class OneShotClient:
1160
1176
  """Delete a browser profile. Async."""
1161
1177
  return await self.acall_free_delete(f"/v1/tools/browser/profiles/{profile_id}")
1162
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
+
1163
1280
  # ------------------------------------------------------------------
1164
1281
  # Paid tool convenience methods
1165
1282
  # ------------------------------------------------------------------
@@ -1765,3 +1882,37 @@ class OneShotClient:
1765
1882
  # Timeout — return the last snapshot. phones_pending is still True
1766
1883
  # so the caller knows phones never arrived.
1767
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.24.0"
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,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()
File without changes