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.
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/.gitignore +9 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/PKG-INFO +1 -1
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/oneshot/__init__.py +2 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/oneshot/_errors.py +8 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/oneshot/client.py +114 -17
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/pyproject.toml +1 -1
- oneshot_python-0.11.0/tests/test_email_payload.py +74 -0
- 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.0 → oneshot_python-0.11.0}/README.md +0 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/tests/__init__.py +0 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.10.0 → oneshot_python-0.11.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.10.0 → 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
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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(
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|