oneshot-python 0.25.0__tar.gz → 0.26.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.26.0}/PKG-INFO +1 -1
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/README.md +25 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/oneshot/__init__.py +6 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/oneshot/_errors.py +22 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/oneshot/_types.py +24 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/oneshot/client.py +234 -3
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/oneshot/physical_mail.py +15 -2
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/pyproject.toml +1 -1
- oneshot_python-0.26.0/tests/test_access_token.py +298 -0
- oneshot_python-0.26.0/tests/test_top_up_credits.py +87 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/.gitignore +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/__init__.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_linkedin.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.25.0 → oneshot_python-0.26.0}/uv.lock +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",
|
|
@@ -115,6 +115,28 @@ class BudgetExceededError(OneShotError):
|
|
|
115
115
|
self.resets_at = resets_at
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
class InsufficientCreditsError(OneShotError):
|
|
119
|
+
"""An access-token (credits-only) session asked for a paid call the agent's
|
|
120
|
+
prepaid credit balance cannot cover. Nothing was charged; top up credits
|
|
121
|
+
and retry, or use a wallet session. ``required``, ``balance`` and
|
|
122
|
+
``shortfall`` are USDC amounts when the API reported them.
|
|
123
|
+
|
|
124
|
+
Mirrors ``InsufficientCreditsError`` in ``libs/agent-sdk/src/errors.ts``.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
message: str,
|
|
130
|
+
required: float | None = None,
|
|
131
|
+
balance: float | None = None,
|
|
132
|
+
shortfall: float | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
super().__init__(message)
|
|
135
|
+
self.required = required
|
|
136
|
+
self.balance = balance
|
|
137
|
+
self.shortfall = shortfall
|
|
138
|
+
|
|
139
|
+
|
|
118
140
|
class BudgetSyncError(OneShotError):
|
|
119
141
|
"""The configured spend budget could not be confirmed with the server, so
|
|
120
142
|
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.26.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
|
|
|
@@ -284,13 +321,28 @@ class OneShotClient:
|
|
|
284
321
|
|
|
285
322
|
def __init__(
|
|
286
323
|
self,
|
|
287
|
-
private_key: str,
|
|
324
|
+
private_key: Optional[str] = None,
|
|
288
325
|
*,
|
|
326
|
+
access_token: Optional[str] = None,
|
|
327
|
+
address: Optional[str] = None,
|
|
289
328
|
base_url: Optional[str] = None,
|
|
290
329
|
debug: bool = False,
|
|
291
330
|
budgets: Optional[AgentBudgetConfig] = None,
|
|
292
331
|
alert_email: Optional[str] = None,
|
|
293
332
|
) -> None:
|
|
333
|
+
"""Two session kinds, mirroring the TypeScript SDK:
|
|
334
|
+
|
|
335
|
+
* **Wallet session** — ``private_key``: signs x402 payments and read
|
|
336
|
+
proofs; may set budgets and mint access tokens.
|
|
337
|
+
* **Access-token session** — ``access_token`` (``oneshot_<64 hex>``) plus
|
|
338
|
+
the agent's ``address`` (or use :meth:`create` / :meth:`acreate` to
|
|
339
|
+
resolve it from the API): identifies the agent with the token, pays
|
|
340
|
+
every call from the agent's prepaid credit balance, never signs; a
|
|
341
|
+
shortfall raises :class:`InsufficientCreditsError`. Budgets and alerts
|
|
342
|
+
are read-only in this mode, and a token cannot mint, list or revoke
|
|
343
|
+
tokens. Meant for agents that run in someone else's cloud (hosted MCP,
|
|
344
|
+
Grok Bot, runners) where a wallet key must never live.
|
|
345
|
+
"""
|
|
294
346
|
self.base_url = base_url or _BASE_URL
|
|
295
347
|
self.chain_id: int = _CHAIN_ID
|
|
296
348
|
self.usdc_address: str = _USDC_ADDRESS
|
|
@@ -304,9 +356,120 @@ class OneShotClient:
|
|
|
304
356
|
self._budget_synced = False
|
|
305
357
|
self._budget_sync_lock: Optional[asyncio.Lock] = None
|
|
306
358
|
|
|
359
|
+
self._access_token: Optional[str] = None
|
|
360
|
+
if access_token is not None:
|
|
361
|
+
if not _ACCESS_TOKEN_RE.match(access_token):
|
|
362
|
+
raise ValidationError("access_token must be oneshot_<64 lowercase hex characters>", "access_token")
|
|
363
|
+
if private_key:
|
|
364
|
+
raise ValidationError("access_token cannot be combined with private_key", "access_token")
|
|
365
|
+
if budgets or alert_email:
|
|
366
|
+
raise ValidationError(
|
|
367
|
+
"budgets and alerts are read-only for access-token sessions; set them from a wallet session or the API",
|
|
368
|
+
"budgets",
|
|
369
|
+
)
|
|
370
|
+
if not address:
|
|
371
|
+
raise ValidationError(
|
|
372
|
+
"address is required with access_token in the constructor; use OneShotClient.create() to resolve it",
|
|
373
|
+
"address",
|
|
374
|
+
)
|
|
375
|
+
try:
|
|
376
|
+
checksummed = to_checksum_address(address)
|
|
377
|
+
except Exception: # noqa: BLE001
|
|
378
|
+
raise ValidationError("address must be a valid Ethereum address", "address") from None
|
|
379
|
+
self._access_token = access_token
|
|
380
|
+
self._private_key: Optional[str] = None
|
|
381
|
+
self.address: str = checksummed
|
|
382
|
+
return
|
|
383
|
+
if not private_key:
|
|
384
|
+
raise ValidationError("private_key is required (or pass access_token for a credits-only session)", "private_key")
|
|
307
385
|
self._private_key = private_key
|
|
308
386
|
acct = Account.from_key(private_key)
|
|
309
|
-
self.address
|
|
387
|
+
self.address = acct.address
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def is_access_token_session(self) -> bool:
|
|
391
|
+
"""True when this session authenticates with an agent access token (credits-only)."""
|
|
392
|
+
return self._access_token is not None
|
|
393
|
+
|
|
394
|
+
@classmethod
|
|
395
|
+
async def acreate(
|
|
396
|
+
cls,
|
|
397
|
+
*,
|
|
398
|
+
access_token: str,
|
|
399
|
+
address: Optional[str] = None,
|
|
400
|
+
base_url: Optional[str] = None,
|
|
401
|
+
debug: bool = False,
|
|
402
|
+
) -> "OneShotClient":
|
|
403
|
+
"""Build an access-token session, resolving the agent's wallet address
|
|
404
|
+
from ``GET /v1/agents/me`` when it is not supplied (the API keys job
|
|
405
|
+
status on ``X-Agent-ID``, so the session needs the address even though
|
|
406
|
+
it never signs with it)."""
|
|
407
|
+
if address:
|
|
408
|
+
return cls(access_token=access_token, address=address, base_url=base_url, debug=debug)
|
|
409
|
+
if not _ACCESS_TOKEN_RE.match(access_token or ""):
|
|
410
|
+
raise ValidationError("access_token must be oneshot_<64 lowercase hex characters>", "access_token")
|
|
411
|
+
url = f"{base_url or _BASE_URL}/v1/agents/me"
|
|
412
|
+
headers = {"Authorization": f"Bearer {access_token}", "X-OneShot-SDK-Version": SDK_VERSION}
|
|
413
|
+
try:
|
|
414
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
|
|
415
|
+
resp = await client.get(url, headers=headers)
|
|
416
|
+
except Exception as e: # noqa: BLE001
|
|
417
|
+
raise ToolError("Could not resolve the access token's agent", 0, str(e)) from e
|
|
418
|
+
if resp.status_code != 200:
|
|
419
|
+
raise ToolError("Could not resolve the access token's agent", resp.status_code, resp.text)
|
|
420
|
+
body = resp.json() if resp.text else {}
|
|
421
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
422
|
+
wallet = (data or {}).get("wallet_address") if isinstance(data, dict) else None
|
|
423
|
+
if not isinstance(wallet, str) or not wallet:
|
|
424
|
+
raise ToolError("Access token lookup returned no wallet address", resp.status_code, resp.text)
|
|
425
|
+
return cls(access_token=access_token, address=wallet, base_url=base_url, debug=debug)
|
|
426
|
+
|
|
427
|
+
@classmethod
|
|
428
|
+
def create(
|
|
429
|
+
cls,
|
|
430
|
+
*,
|
|
431
|
+
access_token: str,
|
|
432
|
+
address: Optional[str] = None,
|
|
433
|
+
base_url: Optional[str] = None,
|
|
434
|
+
debug: bool = False,
|
|
435
|
+
) -> "OneShotClient":
|
|
436
|
+
"""Blocking wrapper for :meth:`acreate`."""
|
|
437
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
438
|
+
cls.acreate(access_token=access_token, address=address, base_url=base_url, debug=debug)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _assert_wallet_session(self, action: str) -> None:
|
|
442
|
+
if self._access_token is not None:
|
|
443
|
+
raise ValidationError(f"Access tokens cannot {action}; use a wallet session", "access_token")
|
|
444
|
+
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
# Access tokens (wallet sessions only)
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
async def acreate_access_token(self, name: Optional[str] = None) -> AccessTokenCreated:
|
|
450
|
+
"""Mint a delegated, revocable credential for a hosted/cloud agent.
|
|
451
|
+
The token is returned once and never stored server-side."""
|
|
452
|
+
self._assert_wallet_session("mint access tokens")
|
|
453
|
+
return self._unwrap_data(await self.acall_free_post("/v1/agents/me/access-tokens", {"name": name} if name else {}, scope="write"))
|
|
454
|
+
|
|
455
|
+
def create_access_token(self, name: Optional[str] = None) -> AccessTokenCreated:
|
|
456
|
+
return asyncio.get_event_loop().run_until_complete(self.acreate_access_token(name))
|
|
457
|
+
|
|
458
|
+
async def alist_access_tokens(self, *, active_only: bool = False) -> list[AccessTokenInfo]:
|
|
459
|
+
self._assert_wallet_session("list access tokens")
|
|
460
|
+
path = "/v1/agents/me/access-tokens" + ("?active=true" if active_only else "")
|
|
461
|
+
data = self._unwrap_data(await self.acall_free_get(path))
|
|
462
|
+
return data.get("tokens", data) if isinstance(data, dict) else data
|
|
463
|
+
|
|
464
|
+
def list_access_tokens(self, *, active_only: bool = False) -> list[AccessTokenInfo]:
|
|
465
|
+
return asyncio.get_event_loop().run_until_complete(self.alist_access_tokens(active_only=active_only))
|
|
466
|
+
|
|
467
|
+
async def arevoke_access_token(self, token_id: str) -> None:
|
|
468
|
+
self._assert_wallet_session("revoke access tokens")
|
|
469
|
+
await self.acall_free_delete(f"/v1/agents/me/access-tokens/{quote(token_id, safe='')}", scope="write")
|
|
470
|
+
|
|
471
|
+
def revoke_access_token(self, token_id: str) -> None:
|
|
472
|
+
asyncio.get_event_loop().run_until_complete(self.arevoke_access_token(token_id))
|
|
310
473
|
|
|
311
474
|
# ------------------------------------------------------------------
|
|
312
475
|
# Headers
|
|
@@ -328,6 +491,10 @@ class OneShotClient:
|
|
|
328
491
|
# in depth.
|
|
329
492
|
if max_cost is not None and max_cost > 0:
|
|
330
493
|
headers["X-Max-Cost-USDC"] = str(max_cost)
|
|
494
|
+
# Access-token session: the bearer binds the request to the agent (and
|
|
495
|
+
# is revocable); the API accepts it in place of the signed proof.
|
|
496
|
+
if self._access_token:
|
|
497
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
331
498
|
return headers
|
|
332
499
|
|
|
333
500
|
@property
|
|
@@ -344,6 +511,8 @@ class OneShotClient:
|
|
|
344
511
|
server runs log-only until read-proof enforcement is enabled).
|
|
345
512
|
Free MUTATION routes (e.g. LinkedIn connect/revoke) require scope="write"."""
|
|
346
513
|
headers = self._headers()
|
|
514
|
+
if self._access_token:
|
|
515
|
+
return headers
|
|
347
516
|
try:
|
|
348
517
|
headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope=scope)
|
|
349
518
|
except Exception as e: # noqa: BLE001
|
|
@@ -356,6 +525,8 @@ class OneShotClient:
|
|
|
356
525
|
those routes outright — there is no log-only fallback — so a signing
|
|
357
526
|
failure here is surfaced rather than swallowed."""
|
|
358
527
|
headers = self._headers()
|
|
528
|
+
if self._access_token:
|
|
529
|
+
return headers
|
|
359
530
|
headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope="write")
|
|
360
531
|
return headers
|
|
361
532
|
|
|
@@ -622,6 +793,12 @@ class OneShotClient:
|
|
|
622
793
|
return result.get("data", result)
|
|
623
794
|
|
|
624
795
|
# Step 2 — Parse 402 response
|
|
796
|
+
if self._access_token:
|
|
797
|
+
# A credits-only session may get a bare insufficient_credits
|
|
798
|
+
# 402 with no quote attached; that is final, nothing to parse.
|
|
799
|
+
shortfall = _parse_insufficient_credits(resp)
|
|
800
|
+
if shortfall is not None:
|
|
801
|
+
raise shortfall
|
|
625
802
|
# Parse x402 v2 PaymentRequired from PAYMENT-REQUIRED header
|
|
626
803
|
parsed_req = parse_payment_required(
|
|
627
804
|
resp.headers.get("payment-required")
|
|
@@ -647,6 +824,37 @@ class OneShotClient:
|
|
|
647
824
|
|
|
648
825
|
self._log(f"Payment required: {payment_request['amount']} USDC")
|
|
649
826
|
|
|
827
|
+
if self._access_token:
|
|
828
|
+
# Credits-only session: never sign. Let the server settle the
|
|
829
|
+
# quote from credits on one retry (no payment-signature).
|
|
830
|
+
if not quote_id:
|
|
831
|
+
amount_str = payment_request.get("amount")
|
|
832
|
+
raise InsufficientCreditsError(
|
|
833
|
+
f"Credits do not cover ${amount_str}; top up credits or use a wallet session"
|
|
834
|
+
if amount_str else "Credits do not cover this call; top up credits or use a wallet session",
|
|
835
|
+
required=float(amount_str) if amount_str else None,
|
|
836
|
+
)
|
|
837
|
+
headers = self._headers(max_cost=max_cost, idempotency_key=idempotency_key)
|
|
838
|
+
headers["x-quote-id"] = quote_id
|
|
839
|
+
resp2 = await client.post(url, headers=headers, json=payload)
|
|
840
|
+
if resp2.status_code not in (200, 201, 202):
|
|
841
|
+
shortfall = _parse_insufficient_credits(resp2)
|
|
842
|
+
if shortfall is not None:
|
|
843
|
+
raise shortfall
|
|
844
|
+
budget_rejection = _parse_budget_rejection(resp2)
|
|
845
|
+
if budget_rejection is not None:
|
|
846
|
+
raise budget_rejection
|
|
847
|
+
raise ToolError("Credits do not cover this call", resp2.status_code, resp2.text)
|
|
848
|
+
result = resp2.json()
|
|
849
|
+
if isinstance(result, dict) and result.get("request_id") and result.get("status") in ("pending", "processing"):
|
|
850
|
+
if not wait:
|
|
851
|
+
return {"request_id": result["request_id"], "status": result["status"]}
|
|
852
|
+
return await self._poll_job(
|
|
853
|
+
client, result["request_id"], timeout_sec,
|
|
854
|
+
wait_for_phones=wait_for_phones, phone_timeout_sec=phone_timeout_sec,
|
|
855
|
+
)
|
|
856
|
+
return result.get("data", result)
|
|
857
|
+
|
|
650
858
|
# Step 3 — Sign x402 payment (zero-cost auth if credits cover full cost)
|
|
651
859
|
charge = _resolve_charge_amount(accepted, payment_request)
|
|
652
860
|
self._assert_within_budget(charge)
|
|
@@ -695,6 +903,9 @@ class OneShotClient:
|
|
|
695
903
|
# A 402 here means the facilitator refused the signature — not
|
|
696
904
|
# the ordinary quote-pay 402 handled above. The API names the
|
|
697
905
|
# cause, so raise the specific error rather than a generic one.
|
|
906
|
+
shortfall = _parse_insufficient_credits(resp2)
|
|
907
|
+
if shortfall is not None:
|
|
908
|
+
raise shortfall
|
|
698
909
|
rejection = _parse_payment_rejection(resp2)
|
|
699
910
|
if rejection is not None:
|
|
700
911
|
raise rejection
|
|
@@ -1058,6 +1269,26 @@ class OneShotClient:
|
|
|
1058
1269
|
f"/v1/compute/{goal_id}/fund", {"amount": amount}
|
|
1059
1270
|
)
|
|
1060
1271
|
|
|
1272
|
+
# ------------------------------------------------------------------
|
|
1273
|
+
# Credits
|
|
1274
|
+
# ------------------------------------------------------------------
|
|
1275
|
+
|
|
1276
|
+
async def atop_up_credits(self, amount: float) -> Any:
|
|
1277
|
+
"""Add prepaid credits by paying the same amount in USDC via x402
|
|
1278
|
+
(``POST /v1/credits/top-up``). Wallet sessions only: credits are what
|
|
1279
|
+
access-token sessions spend from, so a token session raises before any
|
|
1280
|
+
request. Returns ``{"topped_up", "credits_balance", "transaction_id",
|
|
1281
|
+
"settlement_tx", "already_credited"}``."""
|
|
1282
|
+
if getattr(self, "_access_token", None):
|
|
1283
|
+
raise ValidationError("Access tokens cannot top up credits; use a wallet session", "access_token")
|
|
1284
|
+
if isinstance(amount, bool) or not isinstance(amount, (int, float)) or not math.isfinite(amount) or amount <= 0:
|
|
1285
|
+
raise ValidationError("amount must be a positive number", "amount")
|
|
1286
|
+
return await self.acall_tool("/v1/credits/top-up", {"amount": amount, "memo": "credit top-up"})
|
|
1287
|
+
|
|
1288
|
+
def top_up_credits(self, amount: float) -> Any:
|
|
1289
|
+
"""Blocking wrapper for :meth:`atop_up_credits`."""
|
|
1290
|
+
return asyncio.get_event_loop().run_until_complete(self.atop_up_credits(amount))
|
|
1291
|
+
|
|
1061
1292
|
# ------------------------------------------------------------------
|
|
1062
1293
|
# Browser
|
|
1063
1294
|
# ------------------------------------------------------------------
|
|
@@ -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.26.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 == []
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|