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.
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/.gitignore +9 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/PKG-INFO +1 -1
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/oneshot/__init__.py +2 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/oneshot/_errors.py +8 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/oneshot/client.py +55 -7
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/pyproject.toml +1 -1
- oneshot_python-0.11.0/tests/test_emergency_error.py +78 -0
- oneshot_python-0.11.0/tests/test_max_cost_header.py +49 -0
- oneshot_python-0.11.0/tests/test_tag_receipt_value.py +87 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/README.md +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/tests/__init__.py +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.10.1 → oneshot_python-0.11.0}/uv.lock +0 -0
|
@@ -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/
|
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|