oneshot-python 0.10.0__tar.gz → 0.11.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.
@@ -81,3 +81,12 @@ apps/video-service/out/
81
81
 
82
82
  # Simulate-players pool (auto-generated, contains player IDs)
83
83
  scripts/.simulate-pool.json
84
+
85
+ # x402 Bazaar seeder wallet (ephemeral, holds a private key during a partial run)
86
+ scripts/.seed-wallet.json
87
+
88
+ # Generated digest outputs from scripts/weekly-signal-digest.ts
89
+ output/
90
+
91
+ # Claude Code agent metadata (lockfile + local settings)
92
+ .claude/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oneshot-python
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -2,6 +2,7 @@
2
2
 
3
3
  from oneshot._errors import (
4
4
  ContentBlockedError,
5
+ EmergencyNumberError,
5
6
  JobError,
6
7
  JobTimeoutError,
7
8
  OneShotError,
@@ -19,5 +20,6 @@ __all__ = [
19
20
  "JobTimeoutError",
20
21
  "ValidationError",
21
22
  "ContentBlockedError",
23
+ "EmergencyNumberError",
22
24
  "sign_payment_authorization",
23
25
  ]
@@ -46,3 +46,11 @@ class ContentBlockedError(OneShotError):
46
46
  def __init__(self, message: str, categories: list[str]) -> None:
47
47
  super().__init__(message)
48
48
  self.categories = categories
49
+
50
+
51
+ class EmergencyNumberError(OneShotError):
52
+ """Voice call rejected because the target number is an emergency line."""
53
+
54
+ def __init__(self, message: str, blocked_number: str) -> None:
55
+ super().__init__(message)
56
+ self.blocked_number = blocked_number
@@ -17,6 +17,7 @@ from eth_account import Account
17
17
 
18
18
  from oneshot._errors import (
19
19
  ContentBlockedError,
20
+ EmergencyNumberError,
20
21
  JobError,
21
22
  JobTimeoutError,
22
23
  OneShotError,
@@ -30,7 +31,7 @@ from oneshot.x402 import (
30
31
  sign_payment_authorization,
31
32
  )
32
33
 
33
- SDK_VERSION = "0.8.3"
34
+ SDK_VERSION = "0.11.0"
34
35
 
35
36
  # ---------------------------------------------------------------------------
36
37
  # Environment configuration
@@ -44,6 +45,35 @@ _POLL_INTERVAL = 2.0 # seconds
44
45
  _MAX_POLL_RETRIES = 3
45
46
 
46
47
 
48
+ def _build_email_payload(
49
+ to: str,
50
+ subject: str,
51
+ body: str,
52
+ from_domain: Optional[str],
53
+ from_mailbox: Optional[str],
54
+ from_name: Optional[str],
55
+ extra: dict[str, Any],
56
+ ) -> dict[str, Any]:
57
+ """Build the /v1/tools/email/send body in the API's field convention.
58
+
59
+ The API expects ``from_address`` + ``to_address`` (see
60
+ EmailSendRequestSchema). Construct from_address from the mailbox/domain
61
+ and forward the optional display name.
62
+ """
63
+ mailbox = from_mailbox or "agent"
64
+ domain = from_domain or "oneshotagent.com"
65
+ payload: dict[str, Any] = {
66
+ "from_address": f"{mailbox}@{domain}",
67
+ "to_address": to,
68
+ "subject": subject,
69
+ "body": body,
70
+ **extra,
71
+ }
72
+ if from_name:
73
+ payload["from_name"] = from_name
74
+ return payload
75
+
76
+
47
77
  class OneShotClient:
48
78
  """Synchronous + async HTTP client for the OneShot API.
49
79
 
@@ -72,12 +102,19 @@ class OneShotClient:
72
102
  # Headers
73
103
  # ------------------------------------------------------------------
74
104
 
75
- def _headers(self) -> dict[str, str]:
76
- return {
105
+ def _headers(self, max_cost: Optional[float] = None) -> dict[str, str]:
106
+ headers = {
77
107
  "Content-Type": "application/json",
78
108
  "X-Agent-ID": self.address,
79
109
  "X-OneShot-SDK-Version": SDK_VERSION,
80
110
  }
111
+ # Server-side budget cap. The API rejects with HTTP 400
112
+ # `exceeds_caller_budget` before requesting payment if the quoted
113
+ # total exceeds this cap. The local fast-fail below stays as defense
114
+ # in depth.
115
+ if max_cost is not None and max_cost > 0:
116
+ headers["X-Max-Cost-USDC"] = str(max_cost)
117
+ return headers
81
118
 
82
119
  def _log(self, msg: str) -> None:
83
120
  if self.debug:
@@ -154,9 +191,9 @@ class OneShotClient:
154
191
 
155
192
  async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
156
193
  # Step 1 — Initial POST (expect 402 for paid tools)
157
- resp = await client.post(url, headers=self._headers(), json=payload)
194
+ resp = await client.post(url, headers=self._headers(max_cost=max_cost), json=payload)
158
195
 
159
- # Handle validation / content-blocked errors
196
+ # Handle validation / content-blocked / emergency-number errors
160
197
  if resp.status_code == 400:
161
198
  data = resp.json()
162
199
  err_type = data.get("error", "")
@@ -165,6 +202,11 @@ class OneShotClient:
165
202
  data.get("message", "Content blocked"),
166
203
  data.get("categories", []),
167
204
  )
205
+ if err_type == "emergency_number_blocked":
206
+ raise EmergencyNumberError(
207
+ data.get("message", "Emergency number blocked"),
208
+ data.get("blocked_number", ""),
209
+ )
168
210
  raise ValidationError(
169
211
  data.get("message", "Invalid request"), "request"
170
212
  )
@@ -221,7 +263,7 @@ class OneShotClient:
221
263
  extensions=parsed_req.get("extensions"),
222
264
  )
223
265
  headers = {
224
- **self._headers(),
266
+ **self._headers(max_cost=max_cost),
225
267
  "payment-signature": encode_payment_header(auth),
226
268
  }
227
269
  if quote_id:
@@ -243,7 +285,7 @@ class OneShotClient:
243
285
 
244
286
  # Step 4 — Re-POST with payment headers (x402 format)
245
287
  headers = {
246
- **self._headers(),
288
+ **self._headers(max_cost=max_cost),
247
289
  "payment-signature": encode_payment_header(auth),
248
290
  }
249
291
  if quote_id:
@@ -485,19 +527,39 @@ class OneShotClient:
485
527
  """Read a web page and extract content as markdown. Async."""
486
528
  return await self.acall_tool("/v1/tools/web-read", {"url": url, **kwargs})
487
529
 
488
- def email(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
530
+ def email(
531
+ self,
532
+ to: str,
533
+ subject: str,
534
+ body: str,
535
+ *,
536
+ from_domain: Optional[str] = None,
537
+ from_mailbox: Optional[str] = None,
538
+ from_name: Optional[str] = None,
539
+ **kwargs: Any,
540
+ ) -> Any:
489
541
  """Send an email. Blocking."""
490
- payload: dict[str, Any] = {"to": to, "subject": subject, "body": body, **kwargs}
491
- if from_domain:
492
- payload["from_domain"] = from_domain
493
- return self.call_tool("/v1/tools/email/send", payload)
542
+ return self.call_tool(
543
+ "/v1/tools/email/send",
544
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
545
+ )
494
546
 
495
- async def aemail(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
547
+ async def aemail(
548
+ self,
549
+ to: str,
550
+ subject: str,
551
+ body: str,
552
+ *,
553
+ from_domain: Optional[str] = None,
554
+ from_mailbox: Optional[str] = None,
555
+ from_name: Optional[str] = None,
556
+ **kwargs: Any,
557
+ ) -> Any:
496
558
  """Send an email. Async."""
497
- payload: dict[str, Any] = {"to": to, "subject": subject, "body": body, **kwargs}
498
- if from_domain:
499
- payload["from_domain"] = from_domain
500
- return await self.acall_tool("/v1/tools/email/send", payload)
559
+ return await self.acall_tool(
560
+ "/v1/tools/email/send",
561
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
562
+ )
501
563
 
502
564
  def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
503
565
  """Make an AI voice call. Blocking."""
@@ -645,6 +707,41 @@ class OneShotClient:
645
707
  """Get unified balance (on-chain USDC + credits). Async."""
646
708
  return await self.acall_free_get("/v1/tools/balance")
647
709
 
710
+ # ------------------------------------------------------------------
711
+ # Receipts — value tagging (RoCS)
712
+ # ------------------------------------------------------------------
713
+
714
+ def tag_receipt_value(
715
+ self,
716
+ receipt_id: str,
717
+ value_tag: dict[str, Any],
718
+ ) -> dict:
719
+ """Tag a receipt with a value for RoCS computation. Blocking.
720
+
721
+ ``value_tag`` shape: ``{"type": ..., "amount": ..., "label": ...}``.
722
+ ``type`` must be one of ``revenue``, ``lead``, ``conversion``,
723
+ ``savings``, ``engagement``. Tags are stored as ``pending`` until a
724
+ judge service confirms them against inbound signals.
725
+ """
726
+ return asyncio.get_event_loop().run_until_complete(
727
+ self.atag_receipt_value(receipt_id, value_tag)
728
+ )
729
+
730
+ async def atag_receipt_value(
731
+ self,
732
+ receipt_id: str,
733
+ value_tag: dict[str, Any],
734
+ ) -> dict:
735
+ """Tag a receipt with a value for RoCS computation. Async."""
736
+ if not receipt_id:
737
+ raise ValidationError("receipt_id is required", "receipt_id")
738
+ if not isinstance(value_tag, dict) or not value_tag.get("type"):
739
+ raise ValidationError("value_tag.type is required", "value_tag.type")
740
+ return await self.acall_free_patch(
741
+ f"/v1/analytics/receipts/{receipt_id}/value",
742
+ value_tag,
743
+ )
744
+
648
745
  # ------------------------------------------------------------------
649
746
  # Job polling
650
747
  # ------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -0,0 +1,74 @@
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.client import OneShotClient, _build_email_payload
15
+
16
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
17
+
18
+
19
+ # ── _build_email_payload (pure) ───────────────────────────────────────────
20
+
21
+ def test_payload_defaults_to_agent_mailbox():
22
+ p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
23
+ assert p == {
24
+ "from_address": "agent@oneshotagent.com",
25
+ "to_address": "r@x.com",
26
+ "subject": "S",
27
+ "body": "B",
28
+ }
29
+
30
+
31
+ def test_payload_uses_mailbox_domain_and_name():
32
+ p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
33
+ assert p["from_address"] == "jane@acme.com"
34
+ assert p["from_name"] == "Jane Doe"
35
+
36
+
37
+ def test_payload_omits_empty_from_name():
38
+ p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
39
+ assert "from_name" not in p
40
+
41
+
42
+ def test_payload_passes_extra_kwargs():
43
+ p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
44
+ assert p["wait"] is True
45
+
46
+
47
+ # ── email() delegation ────────────────────────────────────────────────────
48
+
49
+ def _client() -> OneShotClient:
50
+ c = OneShotClient(TEST_PRIVATE_KEY)
51
+ c.call_tool = MagicMock(return_value={"request_id": "r1"}) # type: ignore[method-assign]
52
+ return c
53
+
54
+
55
+ def test_email_sends_correct_contract():
56
+ c = _client()
57
+ c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
58
+ endpoint, payload = c.call_tool.call_args[0]
59
+ assert endpoint == "/v1/tools/email/send"
60
+ assert payload["from_address"] == "jane@acme.com"
61
+ assert payload["to_address"] == "r@x.com"
62
+ assert payload["from_name"] == "Jane Doe"
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_aemail_sends_correct_contract():
67
+ c = OneShotClient(TEST_PRIVATE_KEY)
68
+ c.acall_tool = AsyncMock(return_value={"request_id": "r2"}) # type: ignore[method-assign]
69
+ await c.aemail("r@x.com", "Hi", "Body")
70
+ endpoint, payload = c.acall_tool.call_args[0]
71
+ assert endpoint == "/v1/tools/email/send"
72
+ assert payload["from_address"] == "agent@oneshotagent.com"
73
+ assert payload["to_address"] == "r@x.com"
74
+ assert "from_name" not in payload
@@ -0,0 +1,78 @@
1
+ """Tests for EmergencyNumberError — voice-call refusal on emergency lines.
2
+
3
+ Mirrors the TS SDK's EmergencyNumberError (libs/agent-sdk/src/errors.ts:64).
4
+ Both error classes get raised on HTTP 400 ``emergency_number_blocked`` from
5
+ apps/api-service/src/routes/tools/voice.ts (line ~349). The Python client
6
+ maps that error code in ``acall_tool``'s 400-branch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from unittest.mock import AsyncMock, MagicMock
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ from oneshot import EmergencyNumberError, OneShotError
17
+ from oneshot._errors import OneShotError as InternalOneShotError
18
+ from oneshot.client import OneShotClient
19
+
20
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
21
+
22
+
23
+ # ── Class shape ──────────────────────────────────────────────────────────
24
+
25
+ def test_emergency_error_is_oneshot_error_subclass():
26
+ assert issubclass(EmergencyNumberError, OneShotError)
27
+ # Identity check — the public re-export and internal class are the same.
28
+ assert OneShotError is InternalOneShotError
29
+
30
+
31
+ def test_emergency_error_carries_blocked_number():
32
+ err = EmergencyNumberError("Cannot call 911", "+1911")
33
+ assert str(err) == "Cannot call 911"
34
+ assert err.blocked_number == "+1911"
35
+
36
+
37
+ def test_emergency_error_exported_from_package_barrel():
38
+ # Re-export check — ``from oneshot import EmergencyNumberError`` must work.
39
+ import oneshot
40
+ assert "EmergencyNumberError" in oneshot.__all__
41
+ assert oneshot.EmergencyNumberError is EmergencyNumberError
42
+
43
+
44
+ # ── acall_tool raises EmergencyNumberError on 400 emergency_number_blocked ─
45
+
46
+ def _fake_400_response(payload: dict) -> MagicMock:
47
+ resp = MagicMock(spec=httpx.Response)
48
+ resp.status_code = 400
49
+ resp.json = MagicMock(return_value=payload)
50
+ return resp
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_acall_tool_raises_emergency_error_on_blocked_number(monkeypatch):
55
+ """A 400 with error=emergency_number_blocked must surface as
56
+ EmergencyNumberError (not the generic ValidationError fallback)."""
57
+ c = OneShotClient(TEST_PRIVATE_KEY)
58
+
59
+ fake_post = AsyncMock(
60
+ return_value=_fake_400_response({
61
+ "error": "emergency_number_blocked",
62
+ "message": "Calls to emergency services are not permitted",
63
+ "blocked_number": "+1911",
64
+ })
65
+ )
66
+
67
+ class _FakeClient:
68
+ def __init__(self, *_a, **_kw): pass
69
+ async def __aenter__(self): return self
70
+ async def __aexit__(self, *_): return False
71
+ post = fake_post
72
+
73
+ monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
74
+
75
+ with pytest.raises(EmergencyNumberError) as excinfo:
76
+ await c.acall_tool("/v1/tools/voice/call", {"target_number": "+1911"})
77
+ assert excinfo.value.blocked_number == "+1911"
78
+ assert "emergency" in str(excinfo.value).lower()
@@ -0,0 +1,49 @@
1
+ """Tests for the X-Max-Cost-USDC server-side budget cap header.
2
+
3
+ Mirrors tests/unit/max-cost-guard.test.ts on the TS side. The server-side
4
+ parsing is the canonical authority (apps/api-service/src/services/max-cost.ts)
5
+ — these tests just lock in the SDK's emit contract.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from oneshot.client import OneShotClient
11
+
12
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
13
+ HEADER = "X-Max-Cost-USDC"
14
+
15
+
16
+ def _client() -> OneShotClient:
17
+ return OneShotClient(TEST_PRIVATE_KEY)
18
+
19
+
20
+ def test_header_absent_when_max_cost_is_none():
21
+ h = _client()._headers()
22
+ assert HEADER not in h
23
+
24
+
25
+ def test_header_absent_when_max_cost_is_zero():
26
+ h = _client()._headers(max_cost=0)
27
+ assert HEADER not in h
28
+
29
+
30
+ def test_header_absent_when_max_cost_is_negative():
31
+ h = _client()._headers(max_cost=-1.5)
32
+ assert HEADER not in h
33
+
34
+
35
+ def test_header_emitted_when_max_cost_positive():
36
+ h = _client()._headers(max_cost=2.0)
37
+ assert h[HEADER] == "2.0"
38
+
39
+
40
+ def test_header_emitted_for_small_positive_value():
41
+ h = _client()._headers(max_cost=0.01)
42
+ assert h[HEADER] == "0.01"
43
+
44
+
45
+ def test_headers_still_contain_standard_fields():
46
+ h = _client()._headers(max_cost=1.0)
47
+ assert h["Content-Type"] == "application/json"
48
+ assert "X-Agent-ID" in h
49
+ assert "X-OneShot-SDK-Version" in h
@@ -0,0 +1,87 @@
1
+ """Tests for OneShotClient.tag_receipt_value / atag_receipt_value.
2
+
3
+ Mirrors the TS SDK's ``OneShot.tagReceiptValue`` (libs/agent-sdk/src/index.ts).
4
+ The endpoint is PATCH /v1/analytics/receipts/:receipt_id/value — the API
5
+ records the tag as ``pending`` until a judge service confirms it against
6
+ inbound signals.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from unittest.mock import AsyncMock
12
+
13
+ import pytest
14
+
15
+ from oneshot import ValidationError
16
+ from oneshot.client import OneShotClient
17
+
18
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
19
+
20
+
21
+ def _client_with_mocked_patch() -> tuple[OneShotClient, AsyncMock]:
22
+ c = OneShotClient(TEST_PRIVATE_KEY)
23
+ mock = AsyncMock(return_value={"success": True})
24
+ c.acall_free_patch = mock # type: ignore[method-assign]
25
+ return c, mock
26
+
27
+
28
+ # ── Validation ───────────────────────────────────────────────────────────
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_raises_when_receipt_id_empty():
32
+ c, _ = _client_with_mocked_patch()
33
+ with pytest.raises(ValidationError) as excinfo:
34
+ await c.atag_receipt_value("", {"type": "revenue", "amount": 1})
35
+ assert excinfo.value.field == "receipt_id"
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_raises_when_value_tag_missing_type():
40
+ c, _ = _client_with_mocked_patch()
41
+ with pytest.raises(ValidationError) as excinfo:
42
+ await c.atag_receipt_value("rcpt_01HX", {"amount": 1})
43
+ assert excinfo.value.field == "value_tag.type"
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_raises_when_value_tag_not_a_dict():
48
+ c, _ = _client_with_mocked_patch()
49
+ with pytest.raises(ValidationError):
50
+ await c.atag_receipt_value("rcpt_01HX", "revenue") # type: ignore[arg-type]
51
+
52
+
53
+ # ── Happy path — forwards to PATCH /v1/analytics/receipts/:id/value ──────
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_forwards_to_correct_endpoint_and_body():
57
+ c, mock = _client_with_mocked_patch()
58
+ tag = {"type": "revenue", "amount": 5.0, "label": "Lead converted"}
59
+ result = await c.atag_receipt_value("rcpt_01HX", tag)
60
+ assert result == {"success": True}
61
+ mock.assert_awaited_once_with("/v1/analytics/receipts/rcpt_01HX/value", tag)
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_accepts_minimal_valid_tag_with_just_type():
66
+ # The API also rejects tags without amount, but that's a server-side
67
+ # check. The SDK's local guard only enforces .type — same as TS.
68
+ c, mock = _client_with_mocked_patch()
69
+ await c.atag_receipt_value("rcpt_01HX", {"type": "lead"})
70
+ mock.assert_awaited_once()
71
+
72
+
73
+ def test_sync_wrapper_delegates_to_async(monkeypatch):
74
+ # The blocking ``tag_receipt_value`` should call through to the async
75
+ # version via the event loop, matching every other tool method.
76
+ c = OneShotClient(TEST_PRIVATE_KEY)
77
+ called: dict = {}
78
+
79
+ async def fake_async(receipt_id, value_tag):
80
+ called["receipt_id"] = receipt_id
81
+ called["value_tag"] = value_tag
82
+ return {"ok": True}
83
+
84
+ c.atag_receipt_value = fake_async # type: ignore[method-assign]
85
+ result = c.tag_receipt_value("rcpt_01HX", {"type": "savings", "amount": 12})
86
+ assert result == {"ok": True}
87
+ assert called == {"receipt_id": "rcpt_01HX", "value_tag": {"type": "savings", "amount": 12}}
File without changes