oneshot-python 0.25.0__tar.gz → 0.27.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.25.0 → oneshot_python-0.27.0}/PKG-INFO +1 -1
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/README.md +25 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/__init__.py +6 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/_errors.py +28 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/_types.py +24 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/client.py +242 -5
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/physical_mail.py +15 -2
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/pyproject.toml +1 -1
- oneshot_python-0.27.0/tests/test_access_token.py +298 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_payment_rejection.py +22 -0
- oneshot_python-0.27.0/tests/test_top_up_credits.py +87 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/uv.lock +1 -1
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/.gitignore +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/__init__.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_linkedin.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_x402.py +0 -0
|
@@ -66,6 +66,31 @@ Read endpoints (inbox, SMS inbox, notifications, balance, browser profiles) retu
|
|
|
66
66
|
|
|
67
67
|
This is automatic — no extra code beyond `private_key`. **Requires `oneshot-python >= 0.17.0`.** Older versions keep working until the API enables enforcement, after which they must send a valid `x-agent-proof`.
|
|
68
68
|
|
|
69
|
+
## Access tokens (credits-only sessions)
|
|
70
|
+
|
|
71
|
+
Some code can't hold your wallet key — a hosted MCP client such as Grok Bot, a cloud
|
|
72
|
+
runner, a third-party agent platform. Mint an **access token** from a wallet session and
|
|
73
|
+
hand that over instead:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# From a wallet session (once):
|
|
77
|
+
created = client.create_access_token(name="grok-bot") # created["token"] is shown once
|
|
78
|
+
|
|
79
|
+
# In the hosted environment:
|
|
80
|
+
hosted = OneShotClient.create(access_token=created["token"]) # resolves the wallet address
|
|
81
|
+
hosted.call_tool("/v1/tools/search", {"query": "…"}) # billed to the agent's credits
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
An access-token session identifies the agent with the token (no `x-agent-proof`), **pays
|
|
85
|
+
every call from the agent's prepaid credit balance**, and never signs x402. When credits
|
|
86
|
+
can't cover a call it raises `InsufficientCreditsError` (`required`, `balance`,
|
|
87
|
+
`shortfall`) — top up and retry. Budgets set on the agent still apply, with credit-funded
|
|
88
|
+
spend counted; they are **read-only** from a token session (`get_budgets()` works, passing
|
|
89
|
+
`budgets`/`alert_email` raises). A token cannot mint, list, or revoke tokens; do that from
|
|
90
|
+
the wallet session (`list_access_tokens()`, `revoke_access_token(id)`). `access_token` is
|
|
91
|
+
exclusive with `private_key`. Pass `address=` to skip the lookup, and use `acreate` /
|
|
92
|
+
`acreate_access_token` inside `asyncio` programs. **Requires `oneshot-python >= 0.26.0`.**
|
|
93
|
+
|
|
69
94
|
## Spend Budgets
|
|
70
95
|
|
|
71
96
|
Cap what the agent can spend so a runaway loop can't drain the wallet overnight:
|
|
@@ -7,6 +7,7 @@ from oneshot._errors import (
|
|
|
7
7
|
BudgetSyncError,
|
|
8
8
|
ContentBlockedError,
|
|
9
9
|
EmergencyNumberError,
|
|
10
|
+
InsufficientCreditsError,
|
|
10
11
|
JobError,
|
|
11
12
|
JobTimeoutError,
|
|
12
13
|
OneShotError,
|
|
@@ -15,6 +16,8 @@ from oneshot._errors import (
|
|
|
15
16
|
ValidationError,
|
|
16
17
|
)
|
|
17
18
|
from oneshot._types import (
|
|
19
|
+
AccessTokenCreated,
|
|
20
|
+
AccessTokenInfo,
|
|
18
21
|
AgentBudgetConfig,
|
|
19
22
|
AgentBudgetStatus,
|
|
20
23
|
ComputeBudgetStatus,
|
|
@@ -48,7 +51,10 @@ __all__ = [
|
|
|
48
51
|
"ValidationError",
|
|
49
52
|
"ContentBlockedError",
|
|
50
53
|
"EmergencyNumberError",
|
|
54
|
+
"InsufficientCreditsError",
|
|
51
55
|
"sign_payment_authorization",
|
|
56
|
+
"AccessTokenCreated",
|
|
57
|
+
"AccessTokenInfo",
|
|
52
58
|
"AgentBudgetConfig",
|
|
53
59
|
"AgentBudgetStatus",
|
|
54
60
|
"ComputeSchedule",
|
|
@@ -33,12 +33,18 @@ class PaymentError(OneShotError):
|
|
|
33
33
|
expected: dict | None = None,
|
|
34
34
|
received: dict | None = None,
|
|
35
35
|
quote_id: str | None = None,
|
|
36
|
+
payment_attempt_id: str | None = None,
|
|
37
|
+
stage: str | None = None,
|
|
38
|
+
diagnostics: dict | None = None,
|
|
36
39
|
) -> None:
|
|
37
40
|
super().__init__(message)
|
|
38
41
|
self.reason = reason
|
|
39
42
|
self.expected = expected or {}
|
|
40
43
|
self.received = received or {}
|
|
41
44
|
self.quote_id = quote_id
|
|
45
|
+
self.payment_attempt_id = payment_attempt_id
|
|
46
|
+
self.stage = stage
|
|
47
|
+
self.diagnostics = diagnostics
|
|
42
48
|
|
|
43
49
|
|
|
44
50
|
class JobError(OneShotError):
|
|
@@ -115,6 +121,28 @@ class BudgetExceededError(OneShotError):
|
|
|
115
121
|
self.resets_at = resets_at
|
|
116
122
|
|
|
117
123
|
|
|
124
|
+
class InsufficientCreditsError(OneShotError):
|
|
125
|
+
"""An access-token (credits-only) session asked for a paid call the agent's
|
|
126
|
+
prepaid credit balance cannot cover. Nothing was charged; top up credits
|
|
127
|
+
and retry, or use a wallet session. ``required``, ``balance`` and
|
|
128
|
+
``shortfall`` are USDC amounts when the API reported them.
|
|
129
|
+
|
|
130
|
+
Mirrors ``InsufficientCreditsError`` in ``libs/agent-sdk/src/errors.ts``.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
message: str,
|
|
136
|
+
required: float | None = None,
|
|
137
|
+
balance: float | None = None,
|
|
138
|
+
shortfall: float | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
super().__init__(message)
|
|
141
|
+
self.required = required
|
|
142
|
+
self.balance = balance
|
|
143
|
+
self.shortfall = shortfall
|
|
144
|
+
|
|
145
|
+
|
|
118
146
|
class BudgetSyncError(OneShotError):
|
|
119
147
|
"""The configured spend budget could not be confirmed with the server, so
|
|
120
148
|
the paid call was NOT made.
|
|
@@ -267,3 +267,27 @@ class AgentBudgetStatus(TypedDict):
|
|
|
267
267
|
remaining_usdc: Optional[str]
|
|
268
268
|
pct_used: Optional[float]
|
|
269
269
|
resets_at: str
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class AccessTokenCreated(TypedDict):
|
|
273
|
+
"""Response of ``POST /v1/agents/me/access-tokens``. Mirrors ``AccessTokenCreated`` in TS.
|
|
274
|
+
``token`` is shown exactly once and never stored server-side."""
|
|
275
|
+
|
|
276
|
+
id: str
|
|
277
|
+
token: str
|
|
278
|
+
token_prefix: str
|
|
279
|
+
name: Optional[str]
|
|
280
|
+
created_at: str
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class AccessTokenInfo(TypedDict):
|
|
284
|
+
"""One row of ``GET /v1/agents/me/access-tokens``. Mirrors ``AccessTokenInfo`` in TS."""
|
|
285
|
+
|
|
286
|
+
id: str
|
|
287
|
+
token_prefix: str
|
|
288
|
+
name: Optional[str]
|
|
289
|
+
source: str
|
|
290
|
+
last_used_at: Optional[str]
|
|
291
|
+
created_at: str
|
|
292
|
+
revoked_at: Optional[str]
|
|
293
|
+
spent_today_usdc: str
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import math
|
|
12
12
|
import json
|
|
13
|
+
import re
|
|
13
14
|
import time
|
|
14
15
|
from decimal import Decimal
|
|
15
16
|
from typing import Any, Optional
|
|
@@ -17,12 +18,14 @@ from urllib.parse import quote
|
|
|
17
18
|
|
|
18
19
|
import httpx
|
|
19
20
|
from eth_account import Account
|
|
21
|
+
from eth_utils import to_checksum_address
|
|
20
22
|
|
|
21
23
|
from oneshot._errors import (
|
|
22
24
|
BudgetExceededError,
|
|
23
25
|
BudgetSyncError,
|
|
24
26
|
ContentBlockedError,
|
|
25
27
|
EmergencyNumberError,
|
|
28
|
+
InsufficientCreditsError,
|
|
26
29
|
JobError,
|
|
27
30
|
JobTimeoutError,
|
|
28
31
|
OneShotError,
|
|
@@ -31,6 +34,8 @@ from oneshot._errors import (
|
|
|
31
34
|
ValidationError,
|
|
32
35
|
)
|
|
33
36
|
from oneshot._types import (
|
|
37
|
+
AccessTokenCreated,
|
|
38
|
+
AccessTokenInfo,
|
|
34
39
|
AgentBudgetConfig,
|
|
35
40
|
AgentBudgetStatus,
|
|
36
41
|
DomainPoolListResult,
|
|
@@ -51,13 +56,15 @@ try:
|
|
|
51
56
|
|
|
52
57
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
53
58
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
54
|
-
SDK_VERSION = "0.
|
|
59
|
+
SDK_VERSION = "0.27.0"
|
|
55
60
|
|
|
56
61
|
# ---------------------------------------------------------------------------
|
|
57
62
|
# Environment configuration
|
|
58
63
|
# ---------------------------------------------------------------------------
|
|
59
64
|
|
|
60
65
|
_BASE_URL = "https://win.oneshotagent.com"
|
|
66
|
+
# Agent access token (credits-only session), see OneShotClient(access_token=...).
|
|
67
|
+
_ACCESS_TOKEN_RE = re.compile(r"^oneshot_[0-9a-f]{64}$")
|
|
61
68
|
_CHAIN_ID = 8453
|
|
62
69
|
_USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
63
70
|
|
|
@@ -161,6 +168,36 @@ def _parse_budget_rejection(resp: Any) -> Optional[BudgetExceededError]:
|
|
|
161
168
|
)
|
|
162
169
|
|
|
163
170
|
|
|
171
|
+
def _parse_insufficient_credits(resp: Any) -> Optional[InsufficientCreditsError]:
|
|
172
|
+
"""Map a 402 ``insufficient_credits`` body onto :class:`InsufficientCreditsError`.
|
|
173
|
+
|
|
174
|
+
Access-token sessions pay from the agent's prepaid credits and never sign
|
|
175
|
+
x402, so a shortfall is a distinct, actionable failure ("top up") rather
|
|
176
|
+
than a payment rejection. Returns None for any other response.
|
|
177
|
+
"""
|
|
178
|
+
if resp.status_code != 402:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
data = resp.json()
|
|
182
|
+
except Exception: # noqa: BLE001 - non-JSON body
|
|
183
|
+
return None
|
|
184
|
+
if not isinstance(data, dict) or data.get("error") != "insufficient_credits":
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def _num(v: Any) -> float | None:
|
|
188
|
+
try:
|
|
189
|
+
return float(v) if v is not None else None
|
|
190
|
+
except (TypeError, ValueError):
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
return InsufficientCreditsError(
|
|
194
|
+
data.get("message") or "Credit balance does not cover this call",
|
|
195
|
+
required=_num(data.get("required_usdc")),
|
|
196
|
+
balance=_num(data.get("credits_balance")),
|
|
197
|
+
shortfall=_num(data.get("shortfall_usdc")),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
164
201
|
def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
165
202
|
"""Build a ``PaymentError`` from a 402 that names its own cause.
|
|
166
203
|
|
|
@@ -169,13 +206,13 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
|
169
206
|
payment is refused. Returns None for any other response so the caller can
|
|
170
207
|
fall back to ``ToolError``.
|
|
171
208
|
"""
|
|
172
|
-
if resp.status_code
|
|
209
|
+
if resp.status_code not in (402, 500):
|
|
173
210
|
return None
|
|
174
211
|
try:
|
|
175
212
|
data = resp.json()
|
|
176
213
|
except Exception: # noqa: BLE001 - non-JSON body
|
|
177
214
|
return None
|
|
178
|
-
if not isinstance(data, dict) or data.get("error")
|
|
215
|
+
if not isinstance(data, dict) or data.get("error") not in ("payment_verification_failed", "payment_processing_error"):
|
|
179
216
|
return None
|
|
180
217
|
|
|
181
218
|
reason = data.get("reason") or "unknown"
|
|
@@ -193,6 +230,9 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
|
193
230
|
if data.get("message"):
|
|
194
231
|
message += f" ({data['message']})"
|
|
195
232
|
|
|
233
|
+
if data.get("payment_attempt_id"):
|
|
234
|
+
message += f" [payment attempt {data['payment_attempt_id']}]"
|
|
235
|
+
|
|
196
236
|
return PaymentError(
|
|
197
237
|
message,
|
|
198
238
|
reason,
|
|
@@ -204,6 +244,9 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
|
204
244
|
},
|
|
205
245
|
received={"amount": received.get("amount")},
|
|
206
246
|
quote_id=data.get("quote_id"),
|
|
247
|
+
payment_attempt_id=data.get("payment_attempt_id"),
|
|
248
|
+
stage=data.get("stage"),
|
|
249
|
+
diagnostics=data.get("diagnostics"),
|
|
207
250
|
)
|
|
208
251
|
|
|
209
252
|
|
|
@@ -284,13 +327,28 @@ class OneShotClient:
|
|
|
284
327
|
|
|
285
328
|
def __init__(
|
|
286
329
|
self,
|
|
287
|
-
private_key: str,
|
|
330
|
+
private_key: Optional[str] = None,
|
|
288
331
|
*,
|
|
332
|
+
access_token: Optional[str] = None,
|
|
333
|
+
address: Optional[str] = None,
|
|
289
334
|
base_url: Optional[str] = None,
|
|
290
335
|
debug: bool = False,
|
|
291
336
|
budgets: Optional[AgentBudgetConfig] = None,
|
|
292
337
|
alert_email: Optional[str] = None,
|
|
293
338
|
) -> None:
|
|
339
|
+
"""Two session kinds, mirroring the TypeScript SDK:
|
|
340
|
+
|
|
341
|
+
* **Wallet session** — ``private_key``: signs x402 payments and read
|
|
342
|
+
proofs; may set budgets and mint access tokens.
|
|
343
|
+
* **Access-token session** — ``access_token`` (``oneshot_<64 hex>``) plus
|
|
344
|
+
the agent's ``address`` (or use :meth:`create` / :meth:`acreate` to
|
|
345
|
+
resolve it from the API): identifies the agent with the token, pays
|
|
346
|
+
every call from the agent's prepaid credit balance, never signs; a
|
|
347
|
+
shortfall raises :class:`InsufficientCreditsError`. Budgets and alerts
|
|
348
|
+
are read-only in this mode, and a token cannot mint, list or revoke
|
|
349
|
+
tokens. Meant for agents that run in someone else's cloud (hosted MCP,
|
|
350
|
+
Grok Bot, runners) where a wallet key must never live.
|
|
351
|
+
"""
|
|
294
352
|
self.base_url = base_url or _BASE_URL
|
|
295
353
|
self.chain_id: int = _CHAIN_ID
|
|
296
354
|
self.usdc_address: str = _USDC_ADDRESS
|
|
@@ -304,9 +362,120 @@ class OneShotClient:
|
|
|
304
362
|
self._budget_synced = False
|
|
305
363
|
self._budget_sync_lock: Optional[asyncio.Lock] = None
|
|
306
364
|
|
|
365
|
+
self._access_token: Optional[str] = None
|
|
366
|
+
if access_token is not None:
|
|
367
|
+
if not _ACCESS_TOKEN_RE.match(access_token):
|
|
368
|
+
raise ValidationError("access_token must be oneshot_<64 lowercase hex characters>", "access_token")
|
|
369
|
+
if private_key:
|
|
370
|
+
raise ValidationError("access_token cannot be combined with private_key", "access_token")
|
|
371
|
+
if budgets or alert_email:
|
|
372
|
+
raise ValidationError(
|
|
373
|
+
"budgets and alerts are read-only for access-token sessions; set them from a wallet session or the API",
|
|
374
|
+
"budgets",
|
|
375
|
+
)
|
|
376
|
+
if not address:
|
|
377
|
+
raise ValidationError(
|
|
378
|
+
"address is required with access_token in the constructor; use OneShotClient.create() to resolve it",
|
|
379
|
+
"address",
|
|
380
|
+
)
|
|
381
|
+
try:
|
|
382
|
+
checksummed = to_checksum_address(address)
|
|
383
|
+
except Exception: # noqa: BLE001
|
|
384
|
+
raise ValidationError("address must be a valid Ethereum address", "address") from None
|
|
385
|
+
self._access_token = access_token
|
|
386
|
+
self._private_key: Optional[str] = None
|
|
387
|
+
self.address: str = checksummed
|
|
388
|
+
return
|
|
389
|
+
if not private_key:
|
|
390
|
+
raise ValidationError("private_key is required (or pass access_token for a credits-only session)", "private_key")
|
|
307
391
|
self._private_key = private_key
|
|
308
392
|
acct = Account.from_key(private_key)
|
|
309
|
-
self.address
|
|
393
|
+
self.address = acct.address
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def is_access_token_session(self) -> bool:
|
|
397
|
+
"""True when this session authenticates with an agent access token (credits-only)."""
|
|
398
|
+
return self._access_token is not None
|
|
399
|
+
|
|
400
|
+
@classmethod
|
|
401
|
+
async def acreate(
|
|
402
|
+
cls,
|
|
403
|
+
*,
|
|
404
|
+
access_token: str,
|
|
405
|
+
address: Optional[str] = None,
|
|
406
|
+
base_url: Optional[str] = None,
|
|
407
|
+
debug: bool = False,
|
|
408
|
+
) -> "OneShotClient":
|
|
409
|
+
"""Build an access-token session, resolving the agent's wallet address
|
|
410
|
+
from ``GET /v1/agents/me`` when it is not supplied (the API keys job
|
|
411
|
+
status on ``X-Agent-ID``, so the session needs the address even though
|
|
412
|
+
it never signs with it)."""
|
|
413
|
+
if address:
|
|
414
|
+
return cls(access_token=access_token, address=address, base_url=base_url, debug=debug)
|
|
415
|
+
if not _ACCESS_TOKEN_RE.match(access_token or ""):
|
|
416
|
+
raise ValidationError("access_token must be oneshot_<64 lowercase hex characters>", "access_token")
|
|
417
|
+
url = f"{base_url or _BASE_URL}/v1/agents/me"
|
|
418
|
+
headers = {"Authorization": f"Bearer {access_token}", "X-OneShot-SDK-Version": SDK_VERSION}
|
|
419
|
+
try:
|
|
420
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
|
|
421
|
+
resp = await client.get(url, headers=headers)
|
|
422
|
+
except Exception as e: # noqa: BLE001
|
|
423
|
+
raise ToolError("Could not resolve the access token's agent", 0, str(e)) from e
|
|
424
|
+
if resp.status_code != 200:
|
|
425
|
+
raise ToolError("Could not resolve the access token's agent", resp.status_code, resp.text)
|
|
426
|
+
body = resp.json() if resp.text else {}
|
|
427
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
428
|
+
wallet = (data or {}).get("wallet_address") if isinstance(data, dict) else None
|
|
429
|
+
if not isinstance(wallet, str) or not wallet:
|
|
430
|
+
raise ToolError("Access token lookup returned no wallet address", resp.status_code, resp.text)
|
|
431
|
+
return cls(access_token=access_token, address=wallet, base_url=base_url, debug=debug)
|
|
432
|
+
|
|
433
|
+
@classmethod
|
|
434
|
+
def create(
|
|
435
|
+
cls,
|
|
436
|
+
*,
|
|
437
|
+
access_token: str,
|
|
438
|
+
address: Optional[str] = None,
|
|
439
|
+
base_url: Optional[str] = None,
|
|
440
|
+
debug: bool = False,
|
|
441
|
+
) -> "OneShotClient":
|
|
442
|
+
"""Blocking wrapper for :meth:`acreate`."""
|
|
443
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
444
|
+
cls.acreate(access_token=access_token, address=address, base_url=base_url, debug=debug)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def _assert_wallet_session(self, action: str) -> None:
|
|
448
|
+
if self._access_token is not None:
|
|
449
|
+
raise ValidationError(f"Access tokens cannot {action}; use a wallet session", "access_token")
|
|
450
|
+
|
|
451
|
+
# ------------------------------------------------------------------
|
|
452
|
+
# Access tokens (wallet sessions only)
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
async def acreate_access_token(self, name: Optional[str] = None) -> AccessTokenCreated:
|
|
456
|
+
"""Mint a delegated, revocable credential for a hosted/cloud agent.
|
|
457
|
+
The token is returned once and never stored server-side."""
|
|
458
|
+
self._assert_wallet_session("mint access tokens")
|
|
459
|
+
return self._unwrap_data(await self.acall_free_post("/v1/agents/me/access-tokens", {"name": name} if name else {}, scope="write"))
|
|
460
|
+
|
|
461
|
+
def create_access_token(self, name: Optional[str] = None) -> AccessTokenCreated:
|
|
462
|
+
return asyncio.get_event_loop().run_until_complete(self.acreate_access_token(name))
|
|
463
|
+
|
|
464
|
+
async def alist_access_tokens(self, *, active_only: bool = False) -> list[AccessTokenInfo]:
|
|
465
|
+
self._assert_wallet_session("list access tokens")
|
|
466
|
+
path = "/v1/agents/me/access-tokens" + ("?active=true" if active_only else "")
|
|
467
|
+
data = self._unwrap_data(await self.acall_free_get(path))
|
|
468
|
+
return data.get("tokens", data) if isinstance(data, dict) else data
|
|
469
|
+
|
|
470
|
+
def list_access_tokens(self, *, active_only: bool = False) -> list[AccessTokenInfo]:
|
|
471
|
+
return asyncio.get_event_loop().run_until_complete(self.alist_access_tokens(active_only=active_only))
|
|
472
|
+
|
|
473
|
+
async def arevoke_access_token(self, token_id: str) -> None:
|
|
474
|
+
self._assert_wallet_session("revoke access tokens")
|
|
475
|
+
await self.acall_free_delete(f"/v1/agents/me/access-tokens/{quote(token_id, safe='')}", scope="write")
|
|
476
|
+
|
|
477
|
+
def revoke_access_token(self, token_id: str) -> None:
|
|
478
|
+
asyncio.get_event_loop().run_until_complete(self.arevoke_access_token(token_id))
|
|
310
479
|
|
|
311
480
|
# ------------------------------------------------------------------
|
|
312
481
|
# Headers
|
|
@@ -328,6 +497,10 @@ class OneShotClient:
|
|
|
328
497
|
# in depth.
|
|
329
498
|
if max_cost is not None and max_cost > 0:
|
|
330
499
|
headers["X-Max-Cost-USDC"] = str(max_cost)
|
|
500
|
+
# Access-token session: the bearer binds the request to the agent (and
|
|
501
|
+
# is revocable); the API accepts it in place of the signed proof.
|
|
502
|
+
if self._access_token:
|
|
503
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
331
504
|
return headers
|
|
332
505
|
|
|
333
506
|
@property
|
|
@@ -344,6 +517,8 @@ class OneShotClient:
|
|
|
344
517
|
server runs log-only until read-proof enforcement is enabled).
|
|
345
518
|
Free MUTATION routes (e.g. LinkedIn connect/revoke) require scope="write"."""
|
|
346
519
|
headers = self._headers()
|
|
520
|
+
if self._access_token:
|
|
521
|
+
return headers
|
|
347
522
|
try:
|
|
348
523
|
headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope=scope)
|
|
349
524
|
except Exception as e: # noqa: BLE001
|
|
@@ -356,6 +531,8 @@ class OneShotClient:
|
|
|
356
531
|
those routes outright — there is no log-only fallback — so a signing
|
|
357
532
|
failure here is surfaced rather than swallowed."""
|
|
358
533
|
headers = self._headers()
|
|
534
|
+
if self._access_token:
|
|
535
|
+
return headers
|
|
359
536
|
headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope="write")
|
|
360
537
|
return headers
|
|
361
538
|
|
|
@@ -622,6 +799,12 @@ class OneShotClient:
|
|
|
622
799
|
return result.get("data", result)
|
|
623
800
|
|
|
624
801
|
# Step 2 — Parse 402 response
|
|
802
|
+
if self._access_token:
|
|
803
|
+
# A credits-only session may get a bare insufficient_credits
|
|
804
|
+
# 402 with no quote attached; that is final, nothing to parse.
|
|
805
|
+
shortfall = _parse_insufficient_credits(resp)
|
|
806
|
+
if shortfall is not None:
|
|
807
|
+
raise shortfall
|
|
625
808
|
# Parse x402 v2 PaymentRequired from PAYMENT-REQUIRED header
|
|
626
809
|
parsed_req = parse_payment_required(
|
|
627
810
|
resp.headers.get("payment-required")
|
|
@@ -647,6 +830,37 @@ class OneShotClient:
|
|
|
647
830
|
|
|
648
831
|
self._log(f"Payment required: {payment_request['amount']} USDC")
|
|
649
832
|
|
|
833
|
+
if self._access_token:
|
|
834
|
+
# Credits-only session: never sign. Let the server settle the
|
|
835
|
+
# quote from credits on one retry (no payment-signature).
|
|
836
|
+
if not quote_id:
|
|
837
|
+
amount_str = payment_request.get("amount")
|
|
838
|
+
raise InsufficientCreditsError(
|
|
839
|
+
f"Credits do not cover ${amount_str}; top up credits or use a wallet session"
|
|
840
|
+
if amount_str else "Credits do not cover this call; top up credits or use a wallet session",
|
|
841
|
+
required=float(amount_str) if amount_str else None,
|
|
842
|
+
)
|
|
843
|
+
headers = self._headers(max_cost=max_cost, idempotency_key=idempotency_key)
|
|
844
|
+
headers["x-quote-id"] = quote_id
|
|
845
|
+
resp2 = await client.post(url, headers=headers, json=payload)
|
|
846
|
+
if resp2.status_code not in (200, 201, 202):
|
|
847
|
+
shortfall = _parse_insufficient_credits(resp2)
|
|
848
|
+
if shortfall is not None:
|
|
849
|
+
raise shortfall
|
|
850
|
+
budget_rejection = _parse_budget_rejection(resp2)
|
|
851
|
+
if budget_rejection is not None:
|
|
852
|
+
raise budget_rejection
|
|
853
|
+
raise ToolError("Credits do not cover this call", resp2.status_code, resp2.text)
|
|
854
|
+
result = resp2.json()
|
|
855
|
+
if isinstance(result, dict) and result.get("request_id") and result.get("status") in ("pending", "processing"):
|
|
856
|
+
if not wait:
|
|
857
|
+
return {"request_id": result["request_id"], "status": result["status"]}
|
|
858
|
+
return await self._poll_job(
|
|
859
|
+
client, result["request_id"], timeout_sec,
|
|
860
|
+
wait_for_phones=wait_for_phones, phone_timeout_sec=phone_timeout_sec,
|
|
861
|
+
)
|
|
862
|
+
return result.get("data", result)
|
|
863
|
+
|
|
650
864
|
# Step 3 — Sign x402 payment (zero-cost auth if credits cover full cost)
|
|
651
865
|
charge = _resolve_charge_amount(accepted, payment_request)
|
|
652
866
|
self._assert_within_budget(charge)
|
|
@@ -695,6 +909,9 @@ class OneShotClient:
|
|
|
695
909
|
# A 402 here means the facilitator refused the signature — not
|
|
696
910
|
# the ordinary quote-pay 402 handled above. The API names the
|
|
697
911
|
# cause, so raise the specific error rather than a generic one.
|
|
912
|
+
shortfall = _parse_insufficient_credits(resp2)
|
|
913
|
+
if shortfall is not None:
|
|
914
|
+
raise shortfall
|
|
698
915
|
rejection = _parse_payment_rejection(resp2)
|
|
699
916
|
if rejection is not None:
|
|
700
917
|
raise rejection
|
|
@@ -1058,6 +1275,26 @@ class OneShotClient:
|
|
|
1058
1275
|
f"/v1/compute/{goal_id}/fund", {"amount": amount}
|
|
1059
1276
|
)
|
|
1060
1277
|
|
|
1278
|
+
# ------------------------------------------------------------------
|
|
1279
|
+
# Credits
|
|
1280
|
+
# ------------------------------------------------------------------
|
|
1281
|
+
|
|
1282
|
+
async def atop_up_credits(self, amount: float) -> Any:
|
|
1283
|
+
"""Add prepaid credits by paying the same amount in USDC via x402
|
|
1284
|
+
(``POST /v1/credits/top-up``). Wallet sessions only: credits are what
|
|
1285
|
+
access-token sessions spend from, so a token session raises before any
|
|
1286
|
+
request. Returns ``{"topped_up", "credits_balance", "transaction_id",
|
|
1287
|
+
"settlement_tx", "already_credited"}``."""
|
|
1288
|
+
if getattr(self, "_access_token", None):
|
|
1289
|
+
raise ValidationError("Access tokens cannot top up credits; use a wallet session", "access_token")
|
|
1290
|
+
if isinstance(amount, bool) or not isinstance(amount, (int, float)) or not math.isfinite(amount) or amount <= 0:
|
|
1291
|
+
raise ValidationError("amount must be a positive number", "amount")
|
|
1292
|
+
return await self.acall_tool("/v1/credits/top-up", {"amount": amount, "memo": "credit top-up"})
|
|
1293
|
+
|
|
1294
|
+
def top_up_credits(self, amount: float) -> Any:
|
|
1295
|
+
"""Blocking wrapper for :meth:`atop_up_credits`."""
|
|
1296
|
+
return asyncio.get_event_loop().run_until_complete(self.atop_up_credits(amount))
|
|
1297
|
+
|
|
1061
1298
|
# ------------------------------------------------------------------
|
|
1062
1299
|
# Browser
|
|
1063
1300
|
# ------------------------------------------------------------------
|
|
@@ -55,7 +55,8 @@ class PhysicalMail:
|
|
|
55
55
|
scope = 'read' if method == 'GET' else 'submit' if path == '/send' else 'write'
|
|
56
56
|
def headers() -> dict[str, str]:
|
|
57
57
|
h = c._headers(max_cost=max_cost)
|
|
58
|
-
|
|
58
|
+
if not getattr(c, '_access_token', None):
|
|
59
|
+
h['x-agent-proof'] = sign_read_proof(c._private_key, c.address, scope=scope)
|
|
59
60
|
if key:
|
|
60
61
|
h['Idempotency-Key'] = key
|
|
61
62
|
if mime:
|
|
@@ -67,7 +68,19 @@ class PhysicalMail:
|
|
|
67
68
|
kwargs = {'content': body} if mime else {'json': body}
|
|
68
69
|
url = f'{c.base_url}/v1/tools/physical-mail{path}'
|
|
69
70
|
response = await transport.request(method, url, headers=headers(), **kwargs)
|
|
70
|
-
if response.status_code == 402 and path == '/send':
|
|
71
|
+
if response.status_code == 402 and path == '/send' and getattr(c, '_access_token', None):
|
|
72
|
+
# Credits-only session: never sign. The quote is on the order,
|
|
73
|
+
# so one retry lets the server settle it from credits.
|
|
74
|
+
from oneshot.client import _parse_insufficient_credits
|
|
75
|
+
shortfall = _parse_insufficient_credits(response)
|
|
76
|
+
if shortfall is not None:
|
|
77
|
+
raise shortfall
|
|
78
|
+
response = await transport.post(url, headers=headers(), json=body)
|
|
79
|
+
if response.status_code == 402:
|
|
80
|
+
shortfall = _parse_insufficient_credits(response)
|
|
81
|
+
if shortfall is not None:
|
|
82
|
+
raise shortfall
|
|
83
|
+
elif response.status_code == 402 and path == '/send':
|
|
71
84
|
parsed = parse_payment_required(response.headers.get('payment-required'))
|
|
72
85
|
accepted = parsed['accepted']
|
|
73
86
|
payment = response.json()['payment_request']
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.27.0"
|
|
4
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"
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Access-token (credits-only) sessions — parity with
|
|
2
|
+
``tests/unit/sdk-access-token-mode.test.ts`` in the TypeScript SDK.
|
|
3
|
+
|
|
4
|
+
A token session identifies the agent with ``Authorization: Bearer oneshot_…``,
|
|
5
|
+
sends no ``x-agent-proof``, never signs x402, raises ``InsufficientCreditsError``
|
|
6
|
+
on a shortfall, keeps budgets read-only, and cannot mint/list/revoke tokens.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from oneshot import (
|
|
19
|
+
BudgetExceededError,
|
|
20
|
+
InsufficientCreditsError,
|
|
21
|
+
OneShotClient,
|
|
22
|
+
ToolError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
27
|
+
TOKEN = "oneshot_" + "ab" * 32
|
|
28
|
+
WALLET = "0x1234567890123456789012345678901234567890"
|
|
29
|
+
BASE = "http://test.local"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(autouse=True)
|
|
33
|
+
def _fresh_event_loop():
|
|
34
|
+
"""Sync wrappers use ``get_event_loop().run_until_complete``; pytest-asyncio
|
|
35
|
+
closes loops between async tests, so give every test a fresh one."""
|
|
36
|
+
loop = asyncio.new_event_loop()
|
|
37
|
+
asyncio.set_event_loop(loop)
|
|
38
|
+
yield
|
|
39
|
+
loop.close()
|
|
40
|
+
# Leave an OPEN loop behind: this module sorts first, and later modules'
|
|
41
|
+
# sync wrappers call get_event_loop().run_until_complete on the current one.
|
|
42
|
+
asyncio.set_event_loop(asyncio.new_event_loop())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Resp:
|
|
46
|
+
def __init__(self, status_code: int, body: Any = None, headers: dict[str, str] | None = None) -> None:
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
self._body = body if body is not None else {}
|
|
49
|
+
self.headers = headers or {}
|
|
50
|
+
self.text = json.dumps(self._body)
|
|
51
|
+
self.is_success = 200 <= status_code < 300
|
|
52
|
+
|
|
53
|
+
def json(self) -> Any:
|
|
54
|
+
return self._body
|
|
55
|
+
|
|
56
|
+
def raise_for_status(self) -> None:
|
|
57
|
+
if not self.is_success:
|
|
58
|
+
raise ToolError("http error", self.status_code, self.text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _FakeAsyncClient:
|
|
62
|
+
"""An httpx.AsyncClient stand-in that records every request and replays
|
|
63
|
+
the queued responses in order."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, responses: list[_Resp]) -> None:
|
|
66
|
+
self.calls: list[dict[str, Any]] = []
|
|
67
|
+
self._queue = list(responses)
|
|
68
|
+
|
|
69
|
+
async def _send(self, method: str, url: str, **kw: Any) -> _Resp:
|
|
70
|
+
self.calls.append({"method": method, "url": url, "headers": {k.lower(): v for k, v in (kw.get("headers") or {}).items()}, "json": kw.get("json")})
|
|
71
|
+
return self._queue.pop(0)
|
|
72
|
+
|
|
73
|
+
async def post(self, url: str, **kw: Any) -> _Resp:
|
|
74
|
+
return await self._send("POST", url, **kw)
|
|
75
|
+
|
|
76
|
+
async def get(self, url: str, **kw: Any) -> _Resp:
|
|
77
|
+
return await self._send("GET", url, **kw)
|
|
78
|
+
|
|
79
|
+
async def delete(self, url: str, **kw: Any) -> _Resp:
|
|
80
|
+
return await self._send("DELETE", url, **kw)
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> "_FakeAsyncClient":
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _client_mock(responses: list[_Resp]):
|
|
90
|
+
client = _FakeAsyncClient(responses)
|
|
91
|
+
return client, client.calls
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _token_client(**kw: Any) -> OneShotClient:
|
|
95
|
+
return OneShotClient(access_token=TOKEN, address=WALLET, base_url=BASE, **kw)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------- constructor
|
|
99
|
+
|
|
100
|
+
def test_rejects_malformed_token():
|
|
101
|
+
with pytest.raises(ValidationError, match="oneshot_<64 lowercase hex"):
|
|
102
|
+
OneShotClient(access_token="oneshot_nothex", address=WALLET)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_token_is_exclusive_with_private_key():
|
|
106
|
+
with pytest.raises(ValidationError, match="cannot be combined with private_key"):
|
|
107
|
+
OneShotClient(TEST_PRIVATE_KEY, access_token=TOKEN, address=WALLET)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_budgets_and_alerts_are_read_only():
|
|
111
|
+
with pytest.raises(ValidationError, match="read-only"):
|
|
112
|
+
OneShotClient(access_token=TOKEN, address=WALLET, budgets={"daily": 5})
|
|
113
|
+
with pytest.raises(ValidationError, match="read-only"):
|
|
114
|
+
OneShotClient(access_token=TOKEN, address=WALLET, alert_email="a@b.c")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_address_required_and_validated():
|
|
118
|
+
with pytest.raises(ValidationError, match="address is required"):
|
|
119
|
+
OneShotClient(access_token=TOKEN)
|
|
120
|
+
with pytest.raises(ValidationError, match="valid Ethereum address"):
|
|
121
|
+
OneShotClient(access_token=TOKEN, address="0xnope")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_valid_token_session_and_wallet_session_flags():
|
|
125
|
+
c = _token_client()
|
|
126
|
+
assert c.address.lower() == WALLET.lower()
|
|
127
|
+
assert c.is_access_token_session is True
|
|
128
|
+
w = OneShotClient(TEST_PRIVATE_KEY)
|
|
129
|
+
assert w.is_access_token_session is False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_private_key_still_required_for_wallet_sessions():
|
|
133
|
+
with pytest.raises(ValidationError, match="private_key is required"):
|
|
134
|
+
OneShotClient()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------- create()
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_acreate_resolves_wallet_from_agents_me():
|
|
141
|
+
client, calls = _client_mock([_Resp(200, {"success": True, "data": {"agent_id": "x", "wallet_address": WALLET, "auth_mode": "access_token"}})])
|
|
142
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
143
|
+
c = await OneShotClient.acreate(access_token=TOKEN, base_url=BASE)
|
|
144
|
+
assert c.address.lower() == WALLET.lower()
|
|
145
|
+
assert calls[0]["method"] == "GET" and calls[0]["url"] == f"{BASE}/v1/agents/me"
|
|
146
|
+
assert calls[0]["headers"]["authorization"] == f"Bearer {TOKEN}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_acreate_skips_lookup_when_address_given():
|
|
151
|
+
client, calls = _client_mock([])
|
|
152
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
153
|
+
c = await OneShotClient.acreate(access_token=TOKEN, address=WALLET, base_url=BASE)
|
|
154
|
+
assert c.is_access_token_session and calls == []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@pytest.mark.asyncio
|
|
158
|
+
async def test_acreate_surfaces_revoked_token():
|
|
159
|
+
client, _ = _client_mock([_Resp(401, {"error": "access_token_revoked"})])
|
|
160
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
161
|
+
with pytest.raises(ToolError) as ei:
|
|
162
|
+
await OneShotClient.acreate(access_token=TOKEN, base_url=BASE)
|
|
163
|
+
assert ei.value.status_code == 401
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_create_sync_wrapper():
|
|
167
|
+
client, _ = _client_mock([_Resp(200, {"data": {"wallet_address": WALLET}})])
|
|
168
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
169
|
+
c = OneShotClient.create(access_token=TOKEN, base_url=BASE)
|
|
170
|
+
assert c.address.lower() == WALLET.lower()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- headers
|
|
174
|
+
|
|
175
|
+
def test_headers_carry_bearer_and_no_proof():
|
|
176
|
+
c = _token_client()
|
|
177
|
+
h = c._headers()
|
|
178
|
+
assert h["Authorization"] == f"Bearer {TOKEN}"
|
|
179
|
+
assert h["X-Agent-ID"].lower() == WALLET.lower()
|
|
180
|
+
assert "x-agent-proof" not in c._read_headers()
|
|
181
|
+
assert "x-agent-proof" not in c._write_headers()
|
|
182
|
+
assert c._read_headers()["Authorization"] == f"Bearer {TOKEN}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_wallet_session_headers_unchanged():
|
|
186
|
+
w = OneShotClient(TEST_PRIVATE_KEY)
|
|
187
|
+
assert "Authorization" not in w._headers()
|
|
188
|
+
assert "x-agent-proof" in w._write_headers()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------- paid calls
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def test_covered_fixed_price_call_is_a_single_unsigned_post():
|
|
195
|
+
client, calls = _client_mock([_Resp(200, {"data": {"results": []}})])
|
|
196
|
+
c = _token_client()
|
|
197
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
198
|
+
out = await c.acall_tool("/v1/tools/search", {"query": "x", "memo": "t"})
|
|
199
|
+
assert out == {"results": []}
|
|
200
|
+
assert len(calls) == 1
|
|
201
|
+
assert "payment-signature" not in calls[0]["headers"]
|
|
202
|
+
assert calls[0]["headers"]["authorization"] == f"Bearer {TOKEN}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.asyncio
|
|
206
|
+
async def test_insufficient_credits_is_typed_and_never_retried():
|
|
207
|
+
body = {"error": "insufficient_credits", "message": "short", "required_usdc": "0.010000", "credits_balance": "0.002500", "shortfall_usdc": "0.007500"}
|
|
208
|
+
client, calls = _client_mock([_Resp(402, body, {"payment-required": ""})])
|
|
209
|
+
c = _token_client()
|
|
210
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client), patch("oneshot.client.parse_payment_required", return_value={"accepted": {}}):
|
|
211
|
+
with pytest.raises(InsufficientCreditsError) as ei:
|
|
212
|
+
await c.acall_tool("/v1/tools/search", {"query": "x", "memo": "t"})
|
|
213
|
+
assert (ei.value.required, ei.value.balance, ei.value.shortfall) == (0.01, 0.0025, 0.0075)
|
|
214
|
+
assert len(calls) == 1
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.mark.asyncio
|
|
218
|
+
async def test_quote_flow_retries_once_with_quote_id_and_no_signature():
|
|
219
|
+
quote_402 = _Resp(402, {"payment_request": {"amount": "5", "recipient": "0x0", "token_address": "0x0", "chain_id": 8453}, "context": {"quote_id": "q-1"}}, {"payment-required": ""})
|
|
220
|
+
client, calls = _client_mock([quote_402, _Resp(200, {"data": {"funded": True}})])
|
|
221
|
+
c = _token_client()
|
|
222
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client), patch("oneshot.client.parse_payment_required", return_value={"accepted": {}}):
|
|
223
|
+
out = await c.acall_tool("/v1/compute/g1/fund", {"amount": 5, "memo": "t"})
|
|
224
|
+
assert out == {"funded": True}
|
|
225
|
+
assert len(calls) == 2
|
|
226
|
+
assert "x-quote-id" not in calls[0]["headers"]
|
|
227
|
+
assert calls[1]["headers"]["x-quote-id"] == "q-1"
|
|
228
|
+
assert "payment-signature" not in calls[1]["headers"]
|
|
229
|
+
assert calls[1]["headers"]["authorization"] == f"Bearer {TOKEN}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.mark.asyncio
|
|
233
|
+
async def test_quote_retry_shortfall_is_typed():
|
|
234
|
+
quote_402 = _Resp(402, {"payment_request": {"amount": "5"}, "context": {"quote_id": "q-1"}}, {"payment-required": ""})
|
|
235
|
+
short = _Resp(402, {"error": "insufficient_credits", "required_usdc": "5", "credits_balance": "1", "shortfall_usdc": "4"})
|
|
236
|
+
client, calls = _client_mock([quote_402, short])
|
|
237
|
+
c = _token_client()
|
|
238
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client), patch("oneshot.client.parse_payment_required", return_value={"accepted": {}}):
|
|
239
|
+
with pytest.raises(InsufficientCreditsError) as ei:
|
|
240
|
+
await c.acall_tool("/v1/compute/g1/fund", {"amount": 5, "memo": "t"})
|
|
241
|
+
assert (ei.value.required, ei.value.balance) == (5.0, 1.0)
|
|
242
|
+
assert len(calls) == 2
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@pytest.mark.asyncio
|
|
246
|
+
async def test_402_without_quote_id_is_a_shortfall_not_a_signature():
|
|
247
|
+
quote_402 = _Resp(402, {"payment_request": {"amount": "0.01"}, "context": {}}, {"payment-required": ""})
|
|
248
|
+
client, calls = _client_mock([quote_402])
|
|
249
|
+
c = _token_client()
|
|
250
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client), patch("oneshot.client.parse_payment_required", return_value={"accepted": {}}):
|
|
251
|
+
with pytest.raises(InsufficientCreditsError) as ei:
|
|
252
|
+
await c.acall_tool("/v1/tools/search", {"query": "x", "memo": "t"})
|
|
253
|
+
assert ei.value.required == 0.01
|
|
254
|
+
assert len(calls) == 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_budget_exceeded_on_quote_leg_still_typed():
|
|
259
|
+
client, _ = _client_mock([_Resp(403, {"error": "budget_exceeded", "message": "cap", "budget": {"reason": "daily", "cap": "1", "spent": "1", "charge": "0.5"}})])
|
|
260
|
+
c = _token_client()
|
|
261
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
262
|
+
with pytest.raises(BudgetExceededError) as ei:
|
|
263
|
+
await c.acall_tool("/v1/tools/search", {"query": "x", "memo": "t"})
|
|
264
|
+
assert ei.value.reason == "daily"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------- token management
|
|
268
|
+
|
|
269
|
+
@pytest.mark.asyncio
|
|
270
|
+
@pytest.mark.parametrize("enveloped", [False, True])
|
|
271
|
+
async def test_wallet_session_mints_lists_and_revokes(enveloped):
|
|
272
|
+
w = OneShotClient(TEST_PRIVATE_KEY, base_url=BASE)
|
|
273
|
+
wrap = lambda data: {"success": True, "data": data} if enveloped else data
|
|
274
|
+
client, calls = _client_mock([
|
|
275
|
+
_Resp(201, wrap({"id": "t1", "token": TOKEN, "token_prefix": TOKEN[:16], "name": "grok", "created_at": "x"})),
|
|
276
|
+
_Resp(200, wrap({"tokens": [{"id": "t1"}]})),
|
|
277
|
+
_Resp(204, {}),
|
|
278
|
+
])
|
|
279
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
280
|
+
created = await w.acreate_access_token("grok")
|
|
281
|
+
listed = await w.alist_access_tokens(active_only=True)
|
|
282
|
+
await w.arevoke_access_token("t1")
|
|
283
|
+
assert created["id"] == "t1" and listed == [{"id": "t1"}]
|
|
284
|
+
assert calls[0]["url"] == f"{BASE}/v1/agents/me/access-tokens" and calls[0]["json"] == {"name": "grok"}
|
|
285
|
+
assert "x-agent-proof" in calls[0]["headers"]
|
|
286
|
+
assert calls[1]["url"].endswith("/v1/agents/me/access-tokens?active=true")
|
|
287
|
+
assert calls[2]["method"] == "DELETE" and calls[2]["url"].endswith("/v1/agents/me/access-tokens/t1")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@pytest.mark.asyncio
|
|
291
|
+
async def test_token_session_refuses_token_management_before_any_request():
|
|
292
|
+
c = _token_client()
|
|
293
|
+
client, calls = _client_mock([])
|
|
294
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
295
|
+
for coro in (c.acreate_access_token("x"), c.alist_access_tokens(), c.arevoke_access_token("t1")):
|
|
296
|
+
with pytest.raises(ValidationError, match="Access tokens cannot"):
|
|
297
|
+
await coro
|
|
298
|
+
assert calls == []
|
|
@@ -86,3 +86,25 @@ class TestParsePaymentRejection:
|
|
|
86
86
|
err = _parse_payment_rejection(_Resp(402, REJECTION))
|
|
87
87
|
assert isinstance(err, OneShotError)
|
|
88
88
|
assert isinstance(err, Exported)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_diagnostics_and_attempt_correlation():
|
|
92
|
+
diagnostics = {"status": "complete", "findings": [{"code": "unknown"}]}
|
|
93
|
+
error = _parse_payment_rejection(_Resp(402, {
|
|
94
|
+
**REJECTION, "payment_attempt_id": "attempt-123", "stage": "verification", "diagnostics": diagnostics,
|
|
95
|
+
}))
|
|
96
|
+
assert error.payment_attempt_id == "attempt-123"
|
|
97
|
+
assert "attempt-123" in str(error)
|
|
98
|
+
assert error.stage == "verification"
|
|
99
|
+
assert error.diagnostics == diagnostics
|
|
100
|
+
assert PaymentError("old", "unknown").diagnostics is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_transport_and_settlement_correlation():
|
|
104
|
+
error = _parse_payment_rejection(_Resp(500, {
|
|
105
|
+
"error": "payment_processing_error", "payment_attempt_id": "transport-123", "stage": "verification_transport",
|
|
106
|
+
}))
|
|
107
|
+
assert error.payment_attempt_id == "transport-123"
|
|
108
|
+
assert error.stage == "verification_transport"
|
|
109
|
+
error = _parse_payment_rejection(_Resp(402, {**REJECTION, "stage": "settlement"}))
|
|
110
|
+
assert error.stage == "settlement"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""top_up_credits — parity with the TS SDK's topUpCredits (POST /v1/credits/top-up).
|
|
2
|
+
|
|
3
|
+
A wallet session runs the ordinary quote-then-pay dance through ``acall_tool``;
|
|
4
|
+
inputs are validated before any request; a session that carries an access
|
|
5
|
+
token is refused before any request (credits cannot be bought with credits).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from oneshot import OneShotClient, ValidationError
|
|
17
|
+
|
|
18
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def _fresh_event_loop():
|
|
23
|
+
loop = asyncio.new_event_loop()
|
|
24
|
+
asyncio.set_event_loop(loop)
|
|
25
|
+
yield
|
|
26
|
+
loop.close()
|
|
27
|
+
asyncio.set_event_loop(asyncio.new_event_loop())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Resp:
|
|
31
|
+
def __init__(self, status_code: int, body: Any) -> None:
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self._body = body
|
|
34
|
+
self.headers: dict[str, str] = {}
|
|
35
|
+
self.text = "{}"
|
|
36
|
+
self.is_success = 200 <= status_code < 300
|
|
37
|
+
|
|
38
|
+
def json(self) -> Any:
|
|
39
|
+
return self._body
|
|
40
|
+
|
|
41
|
+
def raise_for_status(self) -> None:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _FakeAsyncClient:
|
|
46
|
+
def __init__(self, responses: list[_Resp]) -> None:
|
|
47
|
+
self.calls: list[dict[str, Any]] = []
|
|
48
|
+
self._queue = list(responses)
|
|
49
|
+
|
|
50
|
+
async def post(self, url: str, **kw: Any) -> _Resp:
|
|
51
|
+
self.calls.append({"url": url, "json": kw.get("json"), "headers": kw.get("headers") or {}})
|
|
52
|
+
return self._queue.pop(0)
|
|
53
|
+
|
|
54
|
+
async def __aenter__(self) -> "_FakeAsyncClient":
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_validates_amount_before_any_request():
|
|
62
|
+
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
63
|
+
for bad in (0, -5, None, True, float("nan"), float("inf"), "25"):
|
|
64
|
+
with pytest.raises(ValidationError, match="amount must be a positive number"):
|
|
65
|
+
c.top_up_credits(bad) # type: ignore[arg-type]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_wallet_session_posts_the_amount_to_the_top_up_route():
|
|
70
|
+
client = _FakeAsyncClient([_Resp(200, {"data": {"topped_up": "25.000000", "credits_balance": "25.000000"}})])
|
|
71
|
+
c = OneShotClient(TEST_PRIVATE_KEY, base_url="http://test.local")
|
|
72
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
73
|
+
out = await c.atop_up_credits(25)
|
|
74
|
+
assert out["credits_balance"] == "25.000000"
|
|
75
|
+
assert client.calls[0]["url"] == "http://test.local/v1/credits/top-up"
|
|
76
|
+
assert client.calls[0]["json"]["amount"] == 25
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_access_token_session_is_refused_before_any_request():
|
|
81
|
+
client = _FakeAsyncClient([])
|
|
82
|
+
c = OneShotClient(TEST_PRIVATE_KEY, base_url="http://test.local")
|
|
83
|
+
c._access_token = "oneshot_" + "ab" * 32 # what the access-token constructor sets
|
|
84
|
+
with patch("oneshot.client.httpx.AsyncClient", return_value=client):
|
|
85
|
+
with pytest.raises(ValidationError, match="Access tokens cannot top up credits"):
|
|
86
|
+
await c.atop_up_credits(25)
|
|
87
|
+
assert client.calls == []
|
|
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
|