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.
Files changed (32) hide show
  1. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/PKG-INFO +1 -1
  2. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/README.md +25 -0
  3. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/__init__.py +6 -0
  4. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/_errors.py +28 -0
  5. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/_types.py +24 -0
  6. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/client.py +242 -5
  7. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/physical_mail.py +15 -2
  8. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/pyproject.toml +1 -1
  9. oneshot_python-0.27.0/tests/test_access_token.py +298 -0
  10. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_payment_rejection.py +22 -0
  11. oneshot_python-0.27.0/tests/test_top_up_credits.py +87 -0
  12. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/uv.lock +1 -1
  13. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/.gitignore +0 -0
  14. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/oneshot/x402.py +0 -0
  15. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/__init__.py +0 -0
  16. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_balance.py +0 -0
  17. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_browser.py +0 -0
  18. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_budgets.py +0 -0
  19. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_charge_amount.py +0 -0
  20. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_compute.py +0 -0
  21. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_domains.py +0 -0
  22. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_email_payload.py +0 -0
  23. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_emergency_error.py +0 -0
  24. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_linkedin.py +0 -0
  25. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_max_cost_header.py +0 -0
  26. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_phones_pending.py +0 -0
  27. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_physical_mail.py +0 -0
  28. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_poll_backoff.py +0 -0
  29. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_request_id.py +0 -0
  30. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_tag_receipt_value.py +0 -0
  31. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_wait_false.py +0 -0
  32. {oneshot_python-0.25.0 → oneshot_python-0.27.0}/tests/test_x402.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.25.0
3
+ Version: 0.27.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -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.25.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 != 402:
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") != "payment_verification_failed":
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: str = acct.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
- h['x-agent-proof'] = sign_read_proof(c._private_key, c.address, scope=scope)
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.25.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 == []
@@ -549,7 +549,7 @@ wheels = [
549
549
 
550
550
  [[package]]
551
551
  name = "oneshot-python"
552
- version = "0.24.0"
552
+ version = "0.27.0"
553
553
  source = { editable = "." }
554
554
  dependencies = [
555
555
  { name = "eth-account" },