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