oneshot-python 0.10.1__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.1
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
@@ -101,12 +102,19 @@ class OneShotClient:
101
102
  # Headers
102
103
  # ------------------------------------------------------------------
103
104
 
104
- def _headers(self) -> dict[str, str]:
105
- return {
105
+ def _headers(self, max_cost: Optional[float] = None) -> dict[str, str]:
106
+ headers = {
106
107
  "Content-Type": "application/json",
107
108
  "X-Agent-ID": self.address,
108
109
  "X-OneShot-SDK-Version": SDK_VERSION,
109
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
110
118
 
111
119
  def _log(self, msg: str) -> None:
112
120
  if self.debug:
@@ -183,9 +191,9 @@ class OneShotClient:
183
191
 
184
192
  async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
185
193
  # Step 1 — Initial POST (expect 402 for paid tools)
186
- 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)
187
195
 
188
- # Handle validation / content-blocked errors
196
+ # Handle validation / content-blocked / emergency-number errors
189
197
  if resp.status_code == 400:
190
198
  data = resp.json()
191
199
  err_type = data.get("error", "")
@@ -194,6 +202,11 @@ class OneShotClient:
194
202
  data.get("message", "Content blocked"),
195
203
  data.get("categories", []),
196
204
  )
205
+ if err_type == "emergency_number_blocked":
206
+ raise EmergencyNumberError(
207
+ data.get("message", "Emergency number blocked"),
208
+ data.get("blocked_number", ""),
209
+ )
197
210
  raise ValidationError(
198
211
  data.get("message", "Invalid request"), "request"
199
212
  )
@@ -250,7 +263,7 @@ class OneShotClient:
250
263
  extensions=parsed_req.get("extensions"),
251
264
  )
252
265
  headers = {
253
- **self._headers(),
266
+ **self._headers(max_cost=max_cost),
254
267
  "payment-signature": encode_payment_header(auth),
255
268
  }
256
269
  if quote_id:
@@ -272,7 +285,7 @@ class OneShotClient:
272
285
 
273
286
  # Step 4 — Re-POST with payment headers (x402 format)
274
287
  headers = {
275
- **self._headers(),
288
+ **self._headers(max_cost=max_cost),
276
289
  "payment-signature": encode_payment_header(auth),
277
290
  }
278
291
  if quote_id:
@@ -694,6 +707,41 @@ class OneShotClient:
694
707
  """Get unified balance (on-chain USDC + credits). Async."""
695
708
  return await self.acall_free_get("/v1/tools/balance")
696
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
+
697
745
  # ------------------------------------------------------------------
698
746
  # Job polling
699
747
  # ------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.10.1"
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,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