diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..0ba4720
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,19 @@
+# Normalize line endings in the repository. Without this the working tree's
+# native endings leak into commits: 3 files were converted CRLF->LF inside an
+# unrelated behavioural fix, inflating that diff from 51 real lines to 1045 and
+# burying the change under formatting noise. 14 other tracked files were still
+# CRLF, so the next contributor would have regenerated it.
+* text=auto
+
+# Binary formats git must not touch.
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.pdf binary
+*.woff binary
+*.woff2 binary
+*.mp3 binary
+*.mp4 binary
+*.wav binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 05c0a56..c667fc7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,45 +1,45 @@
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ['3.9', '3.11', '3.12']
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install dependencies
- run: |
- if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then
- pip install -e ".[dev,solana]"
- else
- pip install -e ".[dev]"
- fi
-
- - name: Check formatting
- run: black --check .
-
- - name: Lint
- run: ruff check .
-
- - name: Run unit tests
- run: |
- if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then
- pytest tests/unit
- else
- pytest tests/unit --ignore=tests/unit/test_solana_client.py --ignore=tests/unit/test_solana_wallet.py -k "not SolanaX402"
- fi
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ['3.9', '3.11', '3.12']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then
+ pip install -e ".[dev,solana]"
+ else
+ pip install -e ".[dev]"
+ fi
+
+ - name: Check formatting
+ run: black --check .
+
+ - name: Lint
+ run: ruff check .
+
+ - name: Run unit tests
+ run: |
+ if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then
+ pytest tests/unit
+ else
+ pytest tests/unit --ignore=tests/unit/test_solana_client.py --ignore=tests/unit/test_solana_wallet.py -k "not SolanaX402"
+ fi
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 632c997..5ee5c8e 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -15,8 +15,40 @@ jobs:
with:
python-version: '3.11'
- - name: Install build tools
- run: pip install build
+ # 1.8.1 shipped with VERSION and __init__.py still reading 1.8.0. The
+ # guard test caught it, but only on the push-triggered CI run, after the
+ # release had been cut and published. PyPI does not allow overwriting a
+ # published file, so that artifact is permanently wrong.
+ #
+ # These steps run ci.yml's checks on the 3.11 leg only — black, ruff and
+ # the unit suite. They are NOT full CI: the 3.9 and 3.12 legs still run
+ # only on push, so version-incompatible syntax is caught there, not here.
+ # Extras match the 3.11 CI job so the gate covers the Solana paths; with
+ # the plain extra those tests importorskip and pass silently.
+ - name: Install package and test deps
+ run: pip install -e ".[dev,solana]" build
+
+ # A release tag that disagrees with the package version means the wrong
+ # tree is being published. PyPI would reject a duplicate version, but
+ # only after the release is cut; fail here instead.
+ - name: Verify the release tag matches VERSION
+ run: |
+ tag="${{ github.event.release.tag_name }}"
+ declared="v$(tr -d '[:space:]' < VERSION)"
+ if [ "$tag" != "$declared" ]; then
+ echo "Release tag $tag does not match VERSION ($declared)." >&2
+ exit 1
+ fi
+ echo "Release tag $tag matches VERSION."
+
+ - name: Check formatting
+ run: black --check blockrun_llm/ tests/
+
+ - name: Lint
+ run: ruff check blockrun_llm/ tests/
+
+ - name: Verify the release is publishable
+ run: pytest tests/unit -q
- name: Build package
run: python -m build
diff --git a/.gitignore b/.gitignore
index ff46dc3..df3eca7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,66 +1,66 @@
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-
-# Virtual environments
-venv/
-env/
-.venv/
-.env/
-
-# Environment files
-.env
-.env.local
-.env.*.local
-
-# IDE
-.vscode/
-.idea/
-*.swp
-*.swo
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Test / coverage
-.coverage
-.pytest_cache/
-htmlcov/
-.tox/
-.nox/
-
-# mypy
-.mypy_cache/
-
-# Jupyter
-.ipynb_checkpoints/
-
-# Local Claude Code config (machine-specific allowlists)
-.claude/settings.local.json
-
-# Sweep / test-run artifacts
-sweep-*.json
-sweep-*.log
-
-# Ruff cache
-.ruff_cache/
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environments
+venv/
+env/
+.venv/
+.env/
+
+# Environment files
+.env
+.env.local
+.env.*.local
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Test / coverage
+.coverage
+.pytest_cache/
+htmlcov/
+.tox/
+.nox/
+
+# mypy
+.mypy_cache/
+
+# Jupyter
+.ipynb_checkpoints/
+
+# Local Claude Code config (machine-specific allowlists)
+.claude/settings.local.json
+
+# Sweep / test-run artifacts
+sweep-*.json
+sweep-*.log
+
+# Ruff cache
+.ruff_cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc7cb8e..3263eea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,60 @@
All notable changes to blockrun-llm will be documented in this file.
+## 1.8.2 — 2026-07-21
+
+Supersedes 1.8.1, which was published from a tree where `VERSION` and
+`__init__.py` still read 1.8.0. That wheel reports `__version__ == "1.8.0"` and
+cannot be corrected in place, since PyPI does not allow overwriting a published
+file. Install 1.8.2 to get a package whose self-reported version is truthful.
+
+### Security
+- **A failed paid request no longer triggers a second payment, on either
+ chain.** Any error raised after the `PAYMENT-SIGNATURE` went out is now
+ refused for model fallback, for both Base (`_should_fallback`) and Solana
+ (`_should_fallback_solana`). Previously a post-settlement failure was
+ indistinguishable from a transient one, so the chain advanced to the next
+ model and signed again — `smart_chat` on the premium complex tier could
+ settle six payments and return no tokens, the "CHARGED BUT REQUEST FAILED"
+ outcome this file already documents under 1.7.1. This covers every error
+ escaping the paid leg, not
+ only timeouts: the dominant post-settlement failure is a paid 5xx, which
+ arrives as `APIError(503)` — exactly a status the fallback logic treats as
+ retriable. Callers catching `httpx.TimeoutException` are unaffected; the
+ marker is an attribute, not a new exception type.
+- **The clamp warning cannot break the request it warns about.** Its parse ran
+ on `resource.description`, a server-controlled string, immediately before
+ signing. A non-string value raised `TypeError` and aborted the call, and the
+ pattern backtracked super-linearly on a long digit run (measured on CPython
+ 3.13: 0.49s at 8k digits, 1.95s at 16k, and it keeps squaring). The number is
+ now matched by a bounded pattern against a
+ length-capped slice, an ambiguous description (a per-unit rate alongside the
+ ceiling) stays silent instead of naming the wrong number, and the whole helper
+ swallows its own failures.
+- **Removed a documented payment guard that does not exist.**
+ `chat_completion()` advertised `PaymentError: If budget is set and would be
+ exceeded`. There is no `budget` parameter anywhere in the SDK and no
+ client-side spend cap — every 402 quote is signed automatically. The
+ docstring now says that plainly and points at `get_spending()`.
+
+### Changed
+- **`max_tokens` above a model's ceiling is no longer silently absorbed.** The
+ gateway does not reject an over-ceiling value; it clamps to the model's own
+ ceiling and quotes payment for the clamped value. Verified against the live
+ 402 leg on 2026-07-21: `claude-opus-4.8` sent 262144 and 1000000 both quote
+ the 128000 price, and `gpt-5.2` sent 1e12 returns a quote rather than a 400.
+ The 402 disclosed the clamp in `resource.description` and the SDK discarded
+ it while signing. Callers now get a warning naming what they asked for and
+ what they are being charged for, before the signature goes out.
+- The comment and `ValueError` around `MAX_TOKENS_SANITY_LIMIT` claimed the
+ gateway "enforces the real per-model ceiling and reports it". It does not.
+ Both now describe clamping.
+
+### Fixed
+- Line endings are normalized repo-wide via `.gitattributes` (`* text=auto`).
+ 17 tracked files were CRLF against an otherwise-LF tree, which turned a
+ 51-line change into a 1045-line diff in #27.
+
## 1.8.1 — 2026-07-21
### Fixed
diff --git a/LICENSE b/LICENSE
index 3f30420..7812798 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,21 @@
-MIT License
-
-Copyright (c) 2025 BlockRun
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+MIT License
+
+Copyright (c) 2025 BlockRun
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/VERSION b/VERSION
index a8fdfda..53adb84 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.8.1
+1.8.2
diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py
index 08110f4..1dc80f0 100644
--- a/blockrun_llm/__init__.py
+++ b/blockrun_llm/__init__.py
@@ -178,7 +178,7 @@
)
from .tx_log import TransactionLogger, decode_settlement_header, format_row
-__version__ = "1.8.1"
+__version__ = "1.8.2"
__all__ = [
"LLMClient",
"AsyncLLMClient",
diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py
index 4a3084e..64cabe9 100644
--- a/blockrun_llm/client.py
+++ b/blockrun_llm/client.py
@@ -38,6 +38,7 @@
"""
import os
+import re
import sys
import json as _json
from typing import AsyncIterator, Iterator, List, Dict, Any, Optional, Tuple, Union
@@ -160,6 +161,33 @@ def list_image_models(api_url: str = "https://blockrun.ai/api") -> List[Dict[str
# =============================================================================
+_SETTLED_ATTR = "blockrun_payment_settled"
+
+
+def _mark_settled(exc: BaseException) -> BaseException:
+ """Tag an exception raised after the x402 payment for this call was signed.
+
+ Signing is settlement. Once the PAYMENT-SIGNATURE has gone out, a retry on
+ another model is not a free retry: it triggers a fresh 402, a fresh
+ signature, and a fresh settlement. A six-model fallback chain can therefore
+ settle six times and return nothing, which the CHANGELOG already records as
+ a live outcome class ("CHARGED BUT REQUEST FAILED"). The tag is an
+ attribute rather than a new exception type so callers catching
+ ``httpx.TimeoutException`` keep working unchanged.
+
+ Applied to every exception escaping the paid leg, not just timeouts. The
+ dominant post-settlement failure is a paid 5xx, which surfaces as
+ ``APIError(status_code=503)`` — precisely a status :func:`_should_fallback`
+ treats as retriable, so tagging only timeouts left the six-settlement path
+ fully open. Over-tagging is the safe direction here: the handlers begin at an
+ already-read 402 response and ``create_payment_payload`` is local signing
+ with no network I/O, so the only exceptions that can be tagged without a
+ settlement are ones :func:`_should_fallback` already refuses.
+ """
+ setattr(exc, _SETTLED_ATTR, True)
+ return exc
+
+
def _should_fallback(exc: Exception) -> bool:
"""Whether ``exc`` is the kind of transient failure that warrants trying
the next model in a fallback chain.
@@ -167,9 +195,13 @@ def _should_fallback(exc: Exception) -> bool:
True for: timeouts, network/connection errors, and APIError with 5xx
status codes typically associated with upstream availability problems.
- False for: 4xx client errors, PaymentError (wallet/balance issues), and
- everything else — those are not "swap upstream and retry" situations.
+ False for: 4xx client errors, PaymentError (wallet/balance issues),
+ anything that already cost the caller a settled payment (see
+ :func:`_mark_settled`), and everything else — those are not "swap upstream
+ and retry" situations.
"""
+ if getattr(exc, _SETTLED_ATTR, False):
+ return False
if isinstance(exc, httpx.TimeoutException):
return True
if isinstance(exc, httpx.NetworkError):
@@ -179,6 +211,72 @@ def _should_fallback(exc: Exception) -> bool:
return False
+# The gateway states the output-token ceiling it actually quoted in the 402's
+# ``resource.description``, e.g. "claude-opus-4.8 ... 128000 max output tokens".
+#
+# The alternation is bounded on both branches and the leading lookbehind stops a
+# match from starting mid-number, so there is no super-linear backtracking. The
+# earlier `(\d[\d,]*)` was quadratic on a digit run — measured on CPython 3.13,
+# a string of N '9's: 4k 0.13s, 8k 0.49s, 16k 1.95s, and it keeps squaring. This
+# runs on a server-controlled string inside the payment path, so a long
+# description would have stalled every paid call. Same input, bounded pattern:
+# 0.0002s. `test_pattern_itself_is_not_backtracking` pins it.
+_QUOTED_MAX_TOKENS_RE = re.compile(
+ r"(? None:
+ """Warn when the gateway quoted fewer output tokens than the caller asked for.
+
+ An over-ceiling ``max_tokens`` is not rejected. The gateway silently clamps
+ to the model's ceiling and prices the clamped value, so the caller pays for
+ a ceiling they never asked for and never hears about it. The 402's
+ ``resource.description`` is the only disclosure, and it would otherwise be
+ passed straight into the signature and discarded. Surfacing it here is the
+ caller's one chance to learn their value was dropped before they pay.
+
+ Best-effort by construction: if the description doesn't carry exactly one
+ recognizable ceiling, stay silent rather than guess. A missed warning costs
+ the caller nothing beyond today's behavior; a wrong one would erode trust in
+ all of them. The whole body is guarded because this runs on server-controlled
+ text immediately before signing, and a diagnostic must never be the reason a
+ paid request fails.
+ """
+ try:
+ requested = body.get("max_tokens")
+ # bool is an int subclass; a stray True is not a token count.
+ if not isinstance(requested, int) or isinstance(requested, bool):
+ return
+ # The gateway sends a string here, but the field is server-controlled and
+ # JSON allows anything; a non-string must not reach re.search.
+ if not isinstance(resource_description, str) or not resource_description:
+ return
+
+ matches = _QUOTED_MAX_TOKENS_RE.findall(resource_description[:_DESCRIPTION_SCAN_LIMIT])
+ # Two candidates means the format is not what we think it is (a rate like
+ # "per 1000 max output tokens" would otherwise read as the ceiling).
+ if len(matches) != 1:
+ return
+ quoted = int(matches[0].replace(",", ""))
+
+ if quoted < requested:
+ sys.stderr.write(
+ f"[blockrun_llm] max_tokens clamped by the gateway: you asked for "
+ f"{requested}, {body.get('model', 'this model')} tops out at {quoted}. "
+ f"You are being quoted for {quoted} output tokens, not {requested}.\n"
+ )
+ except Exception:
+ # A warning that breaks the request it is warning about is worse than no
+ # warning. Includes a closed/broken stderr.
+ return
+
+
def _detect_network(api_url: str) -> str:
"""Map an API URL to the canonical network label used in billing
records. Returns ``base-mainnet`` / ``base-sepolia`` / ``solana-mainnet``
@@ -568,7 +666,10 @@ def chat_completion(
ChatResponse object with choices, usage, and citations (if search enabled)
Raises:
- PaymentError: If budget is set and would be exceeded
+ PaymentError: If the gateway rejects the signed payment (most often
+ an insufficient USDC balance). Note that the SDK enforces no
+ client-side spend cap: every 402 quote is signed automatically.
+ Check ``get_spending()`` if you need to bound a session yourself.
Example:
messages = [
@@ -848,7 +949,26 @@ def _stream_with_payment(
raise APIError("stream probe exhausted retries", 0, None)
# ----- Phase 2: stream with PAYMENT-SIGNATURE -----
+ # Signing above was settlement. A timeout here has already been paid
+ # for, so tag it: the stream fallback chain must not settle again on
+ # the next model just because zero chunks arrived.
assert payment_headers is not None # break implies signing succeeded
+ try:
+ yield from self._stream_paid_phase(url, body, payment_headers, cost_usd, timeout)
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
+
+ def _stream_paid_phase(
+ self,
+ url: str,
+ body: Dict[str, Any],
+ payment_headers: Dict[str, str],
+ cost_usd: float,
+ timeout: Optional[float],
+ ) -> Iterator[ChatCompletionChunk]:
+ """Phase 2 of :meth:`_stream_with_payment`: the paid, already-settled leg."""
+ backoffs = self._STREAM_5XX_BACKOFFS
for attempt in range(len(backoffs) + 1):
with self._client.stream(
"POST", url, json=body, headers=payment_headers, timeout=timeout
@@ -1022,6 +1142,7 @@ def _sign_payment_from_response(
)
resource = details.get("resource") or {}
+ _warn_if_clamped(body, resource.get("description"))
extensions = payment_required.get("extensions", {})
payment_payload = create_payment_payload(
account=self.account,
@@ -1085,7 +1206,14 @@ def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> ChatResp
# Handle 402 Payment Required
if response.status_code == 402:
- return self._handle_payment_and_retry(url, body, response)
+ # Everything inside signs first, then makes the paid request, so a
+ # timeout or network error escaping it already cost a settlement.
+ # Tag it so the fallback chain doesn't settle again on the next model.
+ try:
+ return self._handle_payment_and_retry(url, body, response)
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
# Handle other errors
if response.status_code != 200:
@@ -1153,6 +1281,7 @@ def _handle_payment_and_retry(
# Create signed payment payload (v2 format)
# SECURITY: Signing happens locally - only the signature is sent to server
resource = details.get("resource") or {}
+ _warn_if_clamped(body, resource.get("description"))
# Pass through extensions from server (for Bazaar discovery)
extensions = payment_required.get("extensions", {})
payment_payload = create_payment_payload(
@@ -1269,7 +1398,11 @@ def _request_with_payment_raw(self, endpoint: str, body: Dict[str, Any]) -> Dict
response = self._client.post(url, json=body, headers=req_headers)
if response.status_code == 402:
- result = self._handle_payment_and_retry_raw(url, body, response)
+ try:
+ result = self._handle_payment_and_retry_raw(url, body, response)
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
# Save paid response to cache
save_to_cache(
endpoint,
@@ -1329,6 +1462,7 @@ def _handle_payment_and_retry_raw(
)
resource = details.get("resource") or {}
+ _warn_if_clamped(body, resource.get("description"))
extensions = payment_required.get("extensions", {})
payment_payload = create_payment_payload(
account=self.account,
@@ -2481,6 +2615,13 @@ async def chat_completion_stream(
f"[blockrun_llm] stream {attempt_model} -> {next_model} "
f"({type(exc).__name__}: {str(exc)[:80]})\n"
)
+ finally:
+ # `async for` alone does not close `inner` when this generator
+ # is closed or an exception leaves the loop, so an abandoned
+ # stream would strand the paid `async with stream(...)` and its
+ # connection until GC. The sync path gets this from `yield
+ # from`; async has to ask.
+ await inner.aclose()
assert last_exc is not None
raise last_exc
@@ -2530,7 +2671,34 @@ async def _stream_with_payment(
raise APIError("stream probe exhausted retries", 0, None)
# ----- Phase 2: stream with PAYMENT-SIGNATURE -----
+ # Settled from here on; see the sync path.
assert payment_headers is not None
+ # `async for` does NOT close the inner async generator when this one is
+ # closed or an exception leaves the loop, so the paid `async with
+ # self._client.stream(...)` inside it would stay suspended and hold the
+ # connection until GC finalization. The sync path gets this for free:
+ # `yield from` propagates close() into the subgenerator. Close it here.
+ paid = self._astream_paid_phase(url, body, payment_headers, cost_usd, timeout)
+ try:
+ async for chunk in paid:
+ yield chunk
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
+ finally:
+ await paid.aclose()
+
+ async def _astream_paid_phase(
+ self,
+ url: str,
+ body: Dict[str, Any],
+ payment_headers: Dict[str, str],
+ cost_usd: float,
+ timeout: Optional[float],
+ ) -> AsyncIterator[ChatCompletionChunk]:
+ """Phase 2 of the async stream: the paid, already-settled leg."""
+ backoffs = LLMClient._STREAM_5XX_BACKOFFS
+ statuses_5xx = LLMClient._STREAM_5XX_STATUSES
for attempt in range(len(backoffs) + 1):
async with self._client.stream(
"POST", url, json=body, headers=payment_headers, timeout=timeout
@@ -2670,7 +2838,12 @@ async def _request_with_payment(self, endpoint: str, body: Dict[str, Any]) -> Ch
response = await self._client.post(url, json=body, headers=req_headers)
if response.status_code == 402:
- return await self._handle_payment_and_retry(url, body, response)
+ # See the sync path: past this point the payment is settled.
+ try:
+ return await self._handle_payment_and_retry(url, body, response)
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
if response.status_code != 200:
try:
@@ -2718,6 +2891,7 @@ async def _handle_payment_and_retry(
# Create signed payment payload (v2 format)
# SECURITY: Signing happens locally - only the signature is sent to server
resource = details.get("resource") or {}
+ _warn_if_clamped(body, resource.get("description"))
# Pass through extensions from server (for Bazaar discovery)
extensions = payment_required.get("extensions", {})
payment_payload = create_payment_payload(
@@ -2830,7 +3004,11 @@ async def _request_with_payment_raw(
response = await self._client.post(url, json=body, headers=req_headers)
if response.status_code == 402:
- result = await self._handle_payment_and_retry_raw(url, body, response)
+ try:
+ result = await self._handle_payment_and_retry_raw(url, body, response)
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
save_to_cache(
endpoint,
body,
@@ -2881,6 +3059,7 @@ async def _handle_payment_and_retry_raw(
details = extract_payment_details(payment_required)
resource = details.get("resource") or {}
+ _warn_if_clamped(body, resource.get("description"))
extensions = payment_required.get("extensions", {})
payment_payload = create_payment_payload(
account=self.account,
diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py
index ea0bc67..68b52b2 100644
--- a/blockrun_llm/solana_client.py
+++ b/blockrun_llm/solana_client.py
@@ -70,6 +70,12 @@
validate_video_input_type,
)
+# Shared with the Base client: signing is settlement on either chain, so the
+# "already paid, do not retry on another model" tag has to mean the same thing
+# in both fallback chains. client.py does not import this module, so there is
+# no cycle.
+from .client import _SETTLED_ATTR, _mark_settled
+
try:
from x402 import x402ClientSync
from x402.mechanisms.svm import KeypairSigner
@@ -344,7 +350,13 @@ def _should_fallback_solana(exc: Exception) -> bool:
payment classification (``transaction_simulation_failed``, etc.) we
do NOT fall back — re-signing a fresh request hits the same wall in
seconds. The first failure surfaces immediately.
+
+ Also refuses anything already tagged by
+ :func:`blockrun_llm.client._mark_settled`: SPL USDC has left the wallet, and
+ the next model would sign a second transfer for the same call.
"""
+ if getattr(exc, _SETTLED_ATTR, False):
+ return False
# PaymentError always carries the gateway reason now (v0.32.0+).
if isinstance(exc, PaymentError):
return False
@@ -925,27 +937,35 @@ def _stream_once(
# ----- Phase 2: stream with PAYMENT-SIGNATURE -----
assert payment_headers is not None
- for attempt in range(len(backoffs) + 1):
- with self._client.stream(
- "POST", url, json=body, headers=payment_headers, timeout=eff_timeout
- ) as resp2:
- if resp2.status_code == 200:
- if cost_usd > 0:
- self._session_calls += 1
- self._session_total_usd += cost_usd
- self._last_call_cost = cost_usd
- self._capture_settlement(resp2)
- yield from self._iter_and_archive(resp2, body, cost_usd)
- return
- resp2.read()
- if resp2.status_code == 402:
- raise build_payment_rejected_error(resp2)
- if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs):
- import time
+ try:
+ for attempt in range(len(backoffs) + 1):
+ with self._client.stream(
+ "POST", url, json=body, headers=payment_headers, timeout=eff_timeout
+ ) as resp2:
+ if resp2.status_code == 200:
+ if cost_usd > 0:
+ self._session_calls += 1
+ self._session_total_usd += cost_usd
+ self._last_call_cost = cost_usd
+ self._capture_settlement(resp2)
+ yield from self._iter_and_archive(resp2, body, cost_usd)
+ return
+ resp2.read()
+ if resp2.status_code == 402:
+ raise build_payment_rejected_error(resp2)
+ if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs):
+ import time
+
+ time.sleep(backoffs[attempt])
+ continue
+ self._raise_stream_error(resp2, after_payment=True)
- time.sleep(backoffs[attempt])
- continue
- self._raise_stream_error(resp2, after_payment=True)
+ except (httpx.HTTPError, APIError) as exc:
+ # Signed above; SPL USDC is gone. Do not let the fallback
+ # chain buy a retry on the next model. Re-raise bare so the
+ # traceback and __context__ survive.
+ _mark_settled(exc)
+ raise
def _iter_and_archive(
self,
@@ -1127,7 +1147,13 @@ def _request_once(
response = self._client.post(url, json=body, headers=headers, timeout=eff_timeout)
if response.status_code == 402:
- return self._handle_payment_and_retry(url, body, response, timeout=eff_timeout)
+ # Past this point the SPL USDC transfer has been signed. Tag
+ # anything that escapes so no fallback chain can buy a retry.
+ try:
+ return self._handle_payment_and_retry(url, body, response, timeout=eff_timeout)
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
if not response.is_success:
try:
@@ -1241,7 +1267,15 @@ def _request_with_payment_raw(
response = self._client.post(url, json=body, headers=headers, timeout=eff_timeout)
if response.status_code == 402:
- result = self._handle_payment_and_retry_raw(url, body, response, timeout=eff_timeout)
+ # Past this point the SPL USDC transfer has been signed. Tag
+ # anything that escapes so no fallback chain can buy a retry.
+ try:
+ result = self._handle_payment_and_retry_raw(
+ url, body, response, timeout=eff_timeout
+ )
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
save_to_cache(
endpoint,
body,
@@ -3138,28 +3172,36 @@ async def _stream_once(
# ----- Phase 2: stream with PAYMENT-SIGNATURE -----
assert payment_headers is not None
- for attempt in range(len(backoffs) + 1):
- async with self._client.stream(
- "POST", url, json=body, headers=payment_headers, timeout=eff_timeout
- ) as resp2:
- if resp2.status_code == 200:
- if cost_usd > 0:
- self._session_calls += 1
- self._session_total_usd += cost_usd
- self._last_call_cost = cost_usd
- self._capture_settlement(resp2)
- async for chunk in self._aiter_and_archive(resp2, body, cost_usd):
- yield chunk
- return
- await resp2.aread()
- if resp2.status_code == 402:
- raise build_payment_rejected_error(resp2)
- if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs):
- import asyncio
+ try:
+ for attempt in range(len(backoffs) + 1):
+ async with self._client.stream(
+ "POST", url, json=body, headers=payment_headers, timeout=eff_timeout
+ ) as resp2:
+ if resp2.status_code == 200:
+ if cost_usd > 0:
+ self._session_calls += 1
+ self._session_total_usd += cost_usd
+ self._last_call_cost = cost_usd
+ self._capture_settlement(resp2)
+ async for chunk in self._aiter_and_archive(resp2, body, cost_usd):
+ yield chunk
+ return
+ await resp2.aread()
+ if resp2.status_code == 402:
+ raise build_payment_rejected_error(resp2)
+ if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs):
+ import asyncio
+
+ await asyncio.sleep(backoffs[attempt])
+ continue
+ self._raise_stream_error(resp2, after_payment=True)
- await asyncio.sleep(backoffs[attempt])
- continue
- self._raise_stream_error(resp2, after_payment=True)
+ except (httpx.HTTPError, APIError) as exc:
+ # Signed above; SPL USDC is gone. Do not let the fallback
+ # chain buy a retry on the next model. Re-raise bare so the
+ # traceback and __context__ survive.
+ _mark_settled(exc)
+ raise
@staticmethod
async def _aiter_sse_chunks(response: httpx.Response):
@@ -3311,7 +3353,15 @@ async def _request_once(
response = await self._client.post(url, json=body, headers=headers, timeout=eff_timeout)
if response.status_code == 402:
- return await self._handle_payment_and_retry(url, body, response, timeout=eff_timeout)
+ # Past this point the SPL USDC transfer has been signed. Tag
+ # anything that escapes so no fallback chain can buy a retry.
+ try:
+ return await self._handle_payment_and_retry(
+ url, body, response, timeout=eff_timeout
+ )
+ except (httpx.HTTPError, APIError) as exc:
+ _mark_settled(exc)
+ raise
if not response.is_success:
try:
diff --git a/blockrun_llm/solana_wallet.py b/blockrun_llm/solana_wallet.py
index 88e9d92..17d5cff 100644
--- a/blockrun_llm/solana_wallet.py
+++ b/blockrun_llm/solana_wallet.py
@@ -1,577 +1,577 @@
-"""
-BlockRun Solana Wallet Management.
-
-Stores keys as bs58-encoded strings at ~/.blockrun/.solana-session.
-Requires: solders>=0.21.0, base58>=2.1.0
-"""
-
-from __future__ import annotations
-
-import json
-import os
-import time
-from pathlib import Path
-from typing import TYPE_CHECKING, Dict, List, Optional
-
-if TYPE_CHECKING:
- from .solana_client import SolanaLLMClient
-
-WALLET_DIR = Path.home() / ".blockrun"
-SOLANA_WALLET_FILE = WALLET_DIR / ".solana-session"
-
-
-def _require_solders() -> None:
- try:
- import solders # noqa: F401
- except ImportError:
- raise ImportError(
- "Solana support requires 'solders' and 'base58' packages. "
- "Install with: pip install blockrun-llm[solana]"
- )
-
-
-def create_solana_wallet() -> Dict[str, str]:
- """
- Create a new Solana wallet.
-
- Returns:
- Dict with 'address' (base58 pubkey) and 'private_key' (bs58 secret key)
- """
- _require_solders()
- from solders.keypair import Keypair # type: ignore
-
- kp = Keypair()
- return {
- "address": str(kp.pubkey()),
- "private_key": str(kp), # bs58-encoded 64-byte keypair
- }
-
-
-def solana_key_to_bytes(private_key: str) -> bytes:
- """
- Convert a bs58 private key string to bytes (64 bytes).
-
- Accepts both 64-byte full keypairs and 32-byte seeds (from agentcash
- and other providers). 32-byte seeds are automatically expanded.
-
- Args:
- private_key: bs58-encoded Solana secret key (32 or 64 bytes)
-
- Returns:
- 64-byte secret key as bytes
-
- Raises:
- ValueError: If key is invalid
- """
- try:
- from solders.keypair import Keypair # type: ignore
-
- try:
- kp = Keypair.from_base58_string(private_key)
- decoded = bytes(kp)
- if len(decoded) == 64:
- return decoded
- except Exception:
- pass
-
- # Fallback: try as 32-byte seed
- import base58 as b58
-
- decoded = b58.b58decode(private_key)
- if len(decoded) == 32:
- kp = Keypair.from_seed(decoded)
- return bytes(kp)
- elif len(decoded) == 64:
- kp = Keypair.from_seed(decoded[:32])
- return bytes(kp)
-
- raise ValueError(f"Expected 32 or 64 bytes, got {len(decoded)}")
- except Exception as e:
- # Wrap every failure — including the ``ValueError`` modern ``base58``
- # raises on invalid characters — in the documented message. A bare
- # ``except ValueError: raise`` here used to leak base58's raw
- # "Invalid character" error past the wrapper, breaking callers (and
- # the test) that match on "Invalid Solana private key".
- raise ValueError(f"Invalid Solana private key: {e}") from e
-
-
-def get_solana_public_key(private_key: str) -> str:
- """
- Get the Solana public key (address) from a bs58 private key.
-
- Accepts both 64-byte full keypairs and 32-byte seeds.
-
- Args:
- private_key: bs58-encoded Solana secret key (32 or 64 bytes)
-
- Returns:
- Base58 public key string
- """
- _require_solders()
- from solders.keypair import Keypair # type: ignore
-
- try:
- secret = solana_key_to_bytes(private_key)
- kp = Keypair.from_seed(secret[:32])
- return str(kp.pubkey())
- except ValueError:
- # 32-byte seed
- import base58 as b58
-
- decoded = b58.b58decode(private_key)
- if len(decoded) == 32:
- kp = Keypair.from_seed(decoded)
- return str(kp.pubkey())
- raise
-
-
-def save_solana_wallet(private_key: str) -> Path:
- WALLET_DIR.mkdir(exist_ok=True)
- SOLANA_WALLET_FILE.write_text(private_key)
- SOLANA_WALLET_FILE.chmod(0o600)
- return SOLANA_WALLET_FILE
-
-
-def _expand_solana_seed(private_key: str) -> str:
- """If private_key is a 32-byte seed, expand to 64-byte keypair bs58 string."""
- import base58 as b58
- from solders.keypair import Keypair # type: ignore
-
- decoded = b58.b58decode(private_key)
- if len(decoded) == 32:
- kp = Keypair.from_seed(decoded)
- return b58.b58encode(bytes(kp)).decode()
- return private_key
-
-
-def scan_solana_wallets() -> List[Dict[str, str]]:
- """
- Discover ~/.
/solana-wallet.json files from other providers.
-
- Each file should contain JSON with "privateKey" and "address" fields.
- Results are sorted by modification time (most recent first). Discovery is
- opt-in and must never replace the canonical BlockRun wallet automatically.
- 32-byte seeds are automatically converted to 64-byte keypairs.
-
- Returns:
- List of dicts with 'private_key', 'address' and 'source', most recent
- first. 'address' is the file's own claim — use
- list_discovered_solana_wallets() for an address derived from the key.
- """
- home = Path.home()
- results: List[tuple] = [] # (mtime, private_key, address, source)
-
- try:
- for entry in home.iterdir():
- if not entry.name.startswith(".") or not entry.is_dir():
- continue
- wallet_file = entry / "solana-wallet.json"
- if not wallet_file.is_file():
- continue
- try:
- data = json.loads(wallet_file.read_text())
- pk = data.get("privateKey", "")
- addr = data.get("address", "")
- if pk and addr:
- # Expand 32-byte seeds to full keypairs
- try:
- pk = _expand_solana_seed(pk)
- except Exception:
- pass
- mtime = wallet_file.stat().st_mtime
- results.append((mtime, pk, addr, str(wallet_file)))
- except (json.JSONDecodeError, OSError):
- continue
- except OSError:
- pass
-
- # Sort by modification time, most recent first
- results.sort(key=lambda x: x[0], reverse=True)
- return [{"private_key": pk, "address": addr, "source": src} for _, pk, addr, src in results]
-
-
-def list_discovered_solana_wallets() -> List[Dict[str, str]]:
- """
- List Solana wallets from other applications, safe to show to a user.
-
- Solana counterpart of ``wallet.list_discovered_wallets``: no secret key is
- returned and the address is derived from the key rather than trusted from
- the file. Nothing here is active — adopt one with import_solana_wallet().
-
- Returns:
- List of dicts with 'address' and 'source', most recent first
- """
- listed = []
- for entry in scan_solana_wallets():
- try:
- address = get_solana_public_key(entry["private_key"])
- except Exception:
- continue
- listed.append({"address": address, "source": entry.get("source", "")})
- return listed
-
-
-def import_solana_wallet(address: str) -> str:
- """
- Adopt a discovered Solana wallet, making it the active BlockRun wallet.
-
- Solana counterpart of ``wallet.import_wallet``. Matching is done against the
- address derived from each discovered key, and the current
- ~/.blockrun/.solana-session is backed up before being overwritten.
-
- Args:
- address: Address to adopt, as shown by list_discovered_solana_wallets()
-
- Returns:
- The adopted address
-
- Raises:
- ValueError: If no discovered wallet derives to that address
- """
- wanted = address.strip()
-
- for entry in scan_solana_wallets():
- try:
- derived = get_solana_public_key(entry["private_key"])
- except Exception:
- continue
-
- # Base58 is case-sensitive — compare exactly, unlike EVM hex.
- if derived != wanted:
- continue
-
- if SOLANA_WALLET_FILE.exists():
- current = SOLANA_WALLET_FILE.read_text().strip()
- if current and current != entry["private_key"]:
- backup = SOLANA_WALLET_FILE.with_name(f".solana-session.backup-{int(time.time())}")
- backup.write_text(current)
- backup.chmod(0o600)
-
- save_solana_wallet(entry["private_key"])
- return derived
-
- available = [w["address"] for w in list_discovered_solana_wallets()]
- raise ValueError(
- f"No discovered wallet controls {address}. "
- f"Available: {', '.join(available) if available else 'none'}"
- )
-
-
-def load_solana_wallet() -> Optional[str]:
- """
- Load Solana wallet private key.
-
- Priority:
- 1. ~/.blockrun/.solana-session
- """
- # The canonical BlockRun wallet always wins over a discovered provider key.
- if SOLANA_WALLET_FILE.exists():
- try:
- key = SOLANA_WALLET_FILE.read_text().strip()
- except OSError:
- return None # unreadable (bad perms/ownership) → treat as "no wallet"
- if key:
- return key
- return None
-
-
-def get_or_create_solana_wallet() -> Dict[str, object]:
- """
- Get existing Solana wallet or create new one.
-
- Priority:
- 1. SOLANA_WALLET_KEY env var
- 2. ~/.blockrun/.solana-session
- 3. Create new
-
- Returns:
- Dict with 'address', 'private_key', 'is_new'
- """
- # 1. Environment variable
- env_key = os.environ.get("SOLANA_WALLET_KEY")
- if env_key:
- return {"private_key": env_key, "address": get_solana_public_key(env_key), "is_new": False}
-
- # 2. Canonical BlockRun session file. scan_solana_wallets() is exposed
- # only for an explicit migration flow.
- if SOLANA_WALLET_FILE.exists():
- file_key = SOLANA_WALLET_FILE.read_text().strip()
- if file_key:
- return {
- "private_key": file_key,
- "address": get_solana_public_key(file_key),
- "is_new": False,
- }
-
- # 3. Create new
- wallet = create_solana_wallet()
- save_solana_wallet(wallet["private_key"])
- return {**wallet, "is_new": True}
-
-
-def format_solana_wallet_migration_notice(new_address: str) -> Optional[str]:
- """
- Warn when a new Solana wallet was created while provider wallets exist.
-
- Solana counterpart of ``wallet.format_wallet_migration_notice``. Addresses
- are derived from the discovered secret key rather than trusted from the
- file's "address" field.
-
- Args:
- new_address: Address of the wallet that was just created
-
- Returns:
- Formatted notice, or None if nothing was discovered
- """
- try:
- discovered = scan_solana_wallets()
- except Exception:
- return None
-
- addresses = []
- for entry in discovered:
- try:
- addresses.append(get_solana_public_key(entry["private_key"]))
- except Exception:
- continue
-
- if not addresses:
- return None
-
- found = "\n".join(f" {addr}" for addr in addresses)
- return f"""
-NOTICE: BlockRun created a new Solana wallet, but also found existing
-wallet(s) belonging to other applications on this system:
-
-{found}
-
-BlockRun now uses only its own wallet:
-
- {new_address}
-
-Discovered wallets are never adopted automatically — one may belong to a
-different application, or have been planted to make you fund an address you
-do not control.
-
-If an address above is yours and holds your USDC, adopt it deliberately:
-
- from blockrun_llm import import_solana_wallet
- import_solana_wallet("")
-
-Your current wallet is backed up first. You can also set
-SOLANA_WALLET_KEY= for a single run without changing anything.
-"""
-
-
-def setup_agent_solana_wallet(silent: bool = False) -> "SolanaLLMClient":
- """
- Set up Solana wallet for agent use and return a SolanaLLMClient.
-
- This is the entry point for Claude Code skills and other agent runtimes.
- It auto-creates a Solana wallet if needed and prints address if new.
-
- Args:
- silent: If True, don't print welcome message (default: False)
-
- Returns:
- Configured SolanaLLMClient ready for use
-
- Example:
- from blockrun_llm import setup_agent_solana_wallet
-
- client = setup_agent_solana_wallet()
- response = client.chat("openai/gpt-5.2", "Hello!")
- """
- import sys
-
- result = get_or_create_solana_wallet()
-
- if result["is_new"]:
- # Printed even when silent: `silent` suppresses the welcome message,
- # and losing sight of a funded wallet is not something to stay quiet
- # about.
- notice = format_solana_wallet_migration_notice(str(result["address"]))
- if notice:
- print(notice, file=sys.stderr)
-
- if not silent:
- print(f"New Solana wallet created: {result['address']}", file=sys.stderr)
-
- from .solana_client import SolanaLLMClient
-
- return SolanaLLMClient(private_key=result["private_key"])
-
-
-def get_solana_usdc_balance(address: str, rpc_url: Optional[str] = None) -> float:
- """
- Get USDC-SPL balance for a Solana address.
-
- Args:
- address: Solana wallet address (base58)
- rpc_url: Solana RPC endpoint (default: mainnet-beta)
-
- Returns:
- USDC balance as float (6 decimals)
- """
- import httpx
-
- rpc = rpc_url or "https://api.mainnet-beta.solana.com"
- # USDC mint on Solana mainnet
- usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
-
- try:
- resp = httpx.post(
- rpc,
- json={
- "jsonrpc": "2.0",
- "id": 1,
- "method": "getTokenAccountsByOwner",
- "params": [
- address,
- {"mint": usdc_mint},
- {"encoding": "jsonParsed"},
- ],
- },
- timeout=10,
- )
- resp.raise_for_status()
- data = resp.json()
-
- accounts = data.get("result", {}).get("value", [])
- if not accounts:
- return 0.0
-
- # Sum all USDC token accounts (usually just one)
- total = 0.0
- for acct in accounts:
- info = acct.get("account", {}).get("data", {}).get("parsed", {}).get("info", {})
- token_amount = info.get("tokenAmount", {})
- total += float(token_amount.get("uiAmount", 0))
- return total
-
- except Exception:
- return 0.0
-
-
-# QR code file paths for Solana
-SOLANA_QR_FILE = WALLET_DIR / "solana_qr.png"
-SOLANA_QR_ASCII_FILE = WALLET_DIR / "solana_qr.txt"
-
-
-def generate_solana_qr_ascii(address: str) -> str:
- """
- Generate ASCII QR code for Solana wallet funding.
- Uses solana: URI scheme. Caches to ~/.blockrun/solana_qr.txt.
-
- Args:
- address: Solana wallet address (base58)
-
- Returns:
- ASCII art QR code string
- """
- solana_uri = f"solana:{address}"
- cache_key = f"v1:{solana_uri}"
-
- # Try cache
- if SOLANA_QR_ASCII_FILE.exists():
- try:
- cached = SOLANA_QR_ASCII_FILE.read_text()
- lines = cached.split("\n", 1)
- if len(lines) == 2 and lines[0] == cache_key:
- return lines[1]
- except Exception:
- pass
-
- # Generate new QR
- try:
- import qrcode
- from io import StringIO
-
- qr = qrcode.QRCode(
- version=1,
- error_correction=qrcode.constants.ERROR_CORRECT_L,
- box_size=1,
- border=1,
- )
- qr.add_data(solana_uri)
- qr.make(fit=True)
-
- f = StringIO()
- qr.print_ascii(out=f, invert=True)
- qr_ascii = f.getvalue()
-
- # Cache
- try:
- WALLET_DIR.mkdir(exist_ok=True)
- SOLANA_QR_ASCII_FILE.write_text(f"{cache_key}\n{qr_ascii}")
- except Exception:
- pass
-
- return qr_ascii
-
- except ImportError:
- return f"[QR code requires 'qrcode' package: pip install qrcode[pil]]\nAddress: {address}"
-
-
-def save_solana_wallet_qr(address: str, path: Optional[str] = None) -> str:
- """
- Save Solana QR code as PNG image.
-
- Args:
- address: Solana wallet address (base58)
- path: Optional custom path (default: ~/.blockrun/solana_qr.png)
-
- Returns:
- Path to saved QR image, or empty string on failure
- """
- try:
- import qrcode
-
- solana_uri = f"solana:{address}"
-
- qr = qrcode.QRCode(
- version=4,
- error_correction=qrcode.constants.ERROR_CORRECT_L,
- box_size=10,
- border=2,
- )
- qr.add_data(solana_uri)
- qr.make(fit=True)
-
- img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
-
- save_path = Path(path) if path else SOLANA_QR_FILE
- save_path.parent.mkdir(exist_ok=True)
- img.save(str(save_path))
-
- return str(save_path)
-
- except ImportError:
- return ""
-
-
-def open_solana_wallet_qr(address: str) -> str:
- """
- Generate Solana QR code and open it in the default image viewer.
-
- Args:
- address: Solana wallet address (base58)
-
- Returns:
- Path to saved QR image
- """
- import subprocess
- import platform
-
- qr_path = save_solana_wallet_qr(address)
- if qr_path:
- try:
- if platform.system() == "Darwin":
- subprocess.run(["open", qr_path], check=True)
- elif platform.system() == "Windows":
- subprocess.run(["start", qr_path], shell=True, check=True)
- else:
- subprocess.run(["xdg-open", qr_path], check=True)
- except Exception:
- pass
- return qr_path
+"""
+BlockRun Solana Wallet Management.
+
+Stores keys as bs58-encoded strings at ~/.blockrun/.solana-session.
+Requires: solders>=0.21.0, base58>=2.1.0
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Dict, List, Optional
+
+if TYPE_CHECKING:
+ from .solana_client import SolanaLLMClient
+
+WALLET_DIR = Path.home() / ".blockrun"
+SOLANA_WALLET_FILE = WALLET_DIR / ".solana-session"
+
+
+def _require_solders() -> None:
+ try:
+ import solders # noqa: F401
+ except ImportError:
+ raise ImportError(
+ "Solana support requires 'solders' and 'base58' packages. "
+ "Install with: pip install blockrun-llm[solana]"
+ )
+
+
+def create_solana_wallet() -> Dict[str, str]:
+ """
+ Create a new Solana wallet.
+
+ Returns:
+ Dict with 'address' (base58 pubkey) and 'private_key' (bs58 secret key)
+ """
+ _require_solders()
+ from solders.keypair import Keypair # type: ignore
+
+ kp = Keypair()
+ return {
+ "address": str(kp.pubkey()),
+ "private_key": str(kp), # bs58-encoded 64-byte keypair
+ }
+
+
+def solana_key_to_bytes(private_key: str) -> bytes:
+ """
+ Convert a bs58 private key string to bytes (64 bytes).
+
+ Accepts both 64-byte full keypairs and 32-byte seeds (from agentcash
+ and other providers). 32-byte seeds are automatically expanded.
+
+ Args:
+ private_key: bs58-encoded Solana secret key (32 or 64 bytes)
+
+ Returns:
+ 64-byte secret key as bytes
+
+ Raises:
+ ValueError: If key is invalid
+ """
+ try:
+ from solders.keypair import Keypair # type: ignore
+
+ try:
+ kp = Keypair.from_base58_string(private_key)
+ decoded = bytes(kp)
+ if len(decoded) == 64:
+ return decoded
+ except Exception:
+ pass
+
+ # Fallback: try as 32-byte seed
+ import base58 as b58
+
+ decoded = b58.b58decode(private_key)
+ if len(decoded) == 32:
+ kp = Keypair.from_seed(decoded)
+ return bytes(kp)
+ elif len(decoded) == 64:
+ kp = Keypair.from_seed(decoded[:32])
+ return bytes(kp)
+
+ raise ValueError(f"Expected 32 or 64 bytes, got {len(decoded)}")
+ except Exception as e:
+ # Wrap every failure — including the ``ValueError`` modern ``base58``
+ # raises on invalid characters — in the documented message. A bare
+ # ``except ValueError: raise`` here used to leak base58's raw
+ # "Invalid character" error past the wrapper, breaking callers (and
+ # the test) that match on "Invalid Solana private key".
+ raise ValueError(f"Invalid Solana private key: {e}") from e
+
+
+def get_solana_public_key(private_key: str) -> str:
+ """
+ Get the Solana public key (address) from a bs58 private key.
+
+ Accepts both 64-byte full keypairs and 32-byte seeds.
+
+ Args:
+ private_key: bs58-encoded Solana secret key (32 or 64 bytes)
+
+ Returns:
+ Base58 public key string
+ """
+ _require_solders()
+ from solders.keypair import Keypair # type: ignore
+
+ try:
+ secret = solana_key_to_bytes(private_key)
+ kp = Keypair.from_seed(secret[:32])
+ return str(kp.pubkey())
+ except ValueError:
+ # 32-byte seed
+ import base58 as b58
+
+ decoded = b58.b58decode(private_key)
+ if len(decoded) == 32:
+ kp = Keypair.from_seed(decoded)
+ return str(kp.pubkey())
+ raise
+
+
+def save_solana_wallet(private_key: str) -> Path:
+ WALLET_DIR.mkdir(exist_ok=True)
+ SOLANA_WALLET_FILE.write_text(private_key)
+ SOLANA_WALLET_FILE.chmod(0o600)
+ return SOLANA_WALLET_FILE
+
+
+def _expand_solana_seed(private_key: str) -> str:
+ """If private_key is a 32-byte seed, expand to 64-byte keypair bs58 string."""
+ import base58 as b58
+ from solders.keypair import Keypair # type: ignore
+
+ decoded = b58.b58decode(private_key)
+ if len(decoded) == 32:
+ kp = Keypair.from_seed(decoded)
+ return b58.b58encode(bytes(kp)).decode()
+ return private_key
+
+
+def scan_solana_wallets() -> List[Dict[str, str]]:
+ """
+ Discover ~/./solana-wallet.json files from other providers.
+
+ Each file should contain JSON with "privateKey" and "address" fields.
+ Results are sorted by modification time (most recent first). Discovery is
+ opt-in and must never replace the canonical BlockRun wallet automatically.
+ 32-byte seeds are automatically converted to 64-byte keypairs.
+
+ Returns:
+ List of dicts with 'private_key', 'address' and 'source', most recent
+ first. 'address' is the file's own claim — use
+ list_discovered_solana_wallets() for an address derived from the key.
+ """
+ home = Path.home()
+ results: List[tuple] = [] # (mtime, private_key, address, source)
+
+ try:
+ for entry in home.iterdir():
+ if not entry.name.startswith(".") or not entry.is_dir():
+ continue
+ wallet_file = entry / "solana-wallet.json"
+ if not wallet_file.is_file():
+ continue
+ try:
+ data = json.loads(wallet_file.read_text())
+ pk = data.get("privateKey", "")
+ addr = data.get("address", "")
+ if pk and addr:
+ # Expand 32-byte seeds to full keypairs
+ try:
+ pk = _expand_solana_seed(pk)
+ except Exception:
+ pass
+ mtime = wallet_file.stat().st_mtime
+ results.append((mtime, pk, addr, str(wallet_file)))
+ except (json.JSONDecodeError, OSError):
+ continue
+ except OSError:
+ pass
+
+ # Sort by modification time, most recent first
+ results.sort(key=lambda x: x[0], reverse=True)
+ return [{"private_key": pk, "address": addr, "source": src} for _, pk, addr, src in results]
+
+
+def list_discovered_solana_wallets() -> List[Dict[str, str]]:
+ """
+ List Solana wallets from other applications, safe to show to a user.
+
+ Solana counterpart of ``wallet.list_discovered_wallets``: no secret key is
+ returned and the address is derived from the key rather than trusted from
+ the file. Nothing here is active — adopt one with import_solana_wallet().
+
+ Returns:
+ List of dicts with 'address' and 'source', most recent first
+ """
+ listed = []
+ for entry in scan_solana_wallets():
+ try:
+ address = get_solana_public_key(entry["private_key"])
+ except Exception:
+ continue
+ listed.append({"address": address, "source": entry.get("source", "")})
+ return listed
+
+
+def import_solana_wallet(address: str) -> str:
+ """
+ Adopt a discovered Solana wallet, making it the active BlockRun wallet.
+
+ Solana counterpart of ``wallet.import_wallet``. Matching is done against the
+ address derived from each discovered key, and the current
+ ~/.blockrun/.solana-session is backed up before being overwritten.
+
+ Args:
+ address: Address to adopt, as shown by list_discovered_solana_wallets()
+
+ Returns:
+ The adopted address
+
+ Raises:
+ ValueError: If no discovered wallet derives to that address
+ """
+ wanted = address.strip()
+
+ for entry in scan_solana_wallets():
+ try:
+ derived = get_solana_public_key(entry["private_key"])
+ except Exception:
+ continue
+
+ # Base58 is case-sensitive — compare exactly, unlike EVM hex.
+ if derived != wanted:
+ continue
+
+ if SOLANA_WALLET_FILE.exists():
+ current = SOLANA_WALLET_FILE.read_text().strip()
+ if current and current != entry["private_key"]:
+ backup = SOLANA_WALLET_FILE.with_name(f".solana-session.backup-{int(time.time())}")
+ backup.write_text(current)
+ backup.chmod(0o600)
+
+ save_solana_wallet(entry["private_key"])
+ return derived
+
+ available = [w["address"] for w in list_discovered_solana_wallets()]
+ raise ValueError(
+ f"No discovered wallet controls {address}. "
+ f"Available: {', '.join(available) if available else 'none'}"
+ )
+
+
+def load_solana_wallet() -> Optional[str]:
+ """
+ Load Solana wallet private key.
+
+ Priority:
+ 1. ~/.blockrun/.solana-session
+ """
+ # The canonical BlockRun wallet always wins over a discovered provider key.
+ if SOLANA_WALLET_FILE.exists():
+ try:
+ key = SOLANA_WALLET_FILE.read_text().strip()
+ except OSError:
+ return None # unreadable (bad perms/ownership) → treat as "no wallet"
+ if key:
+ return key
+ return None
+
+
+def get_or_create_solana_wallet() -> Dict[str, object]:
+ """
+ Get existing Solana wallet or create new one.
+
+ Priority:
+ 1. SOLANA_WALLET_KEY env var
+ 2. ~/.blockrun/.solana-session
+ 3. Create new
+
+ Returns:
+ Dict with 'address', 'private_key', 'is_new'
+ """
+ # 1. Environment variable
+ env_key = os.environ.get("SOLANA_WALLET_KEY")
+ if env_key:
+ return {"private_key": env_key, "address": get_solana_public_key(env_key), "is_new": False}
+
+ # 2. Canonical BlockRun session file. scan_solana_wallets() is exposed
+ # only for an explicit migration flow.
+ if SOLANA_WALLET_FILE.exists():
+ file_key = SOLANA_WALLET_FILE.read_text().strip()
+ if file_key:
+ return {
+ "private_key": file_key,
+ "address": get_solana_public_key(file_key),
+ "is_new": False,
+ }
+
+ # 3. Create new
+ wallet = create_solana_wallet()
+ save_solana_wallet(wallet["private_key"])
+ return {**wallet, "is_new": True}
+
+
+def format_solana_wallet_migration_notice(new_address: str) -> Optional[str]:
+ """
+ Warn when a new Solana wallet was created while provider wallets exist.
+
+ Solana counterpart of ``wallet.format_wallet_migration_notice``. Addresses
+ are derived from the discovered secret key rather than trusted from the
+ file's "address" field.
+
+ Args:
+ new_address: Address of the wallet that was just created
+
+ Returns:
+ Formatted notice, or None if nothing was discovered
+ """
+ try:
+ discovered = scan_solana_wallets()
+ except Exception:
+ return None
+
+ addresses = []
+ for entry in discovered:
+ try:
+ addresses.append(get_solana_public_key(entry["private_key"]))
+ except Exception:
+ continue
+
+ if not addresses:
+ return None
+
+ found = "\n".join(f" {addr}" for addr in addresses)
+ return f"""
+NOTICE: BlockRun created a new Solana wallet, but also found existing
+wallet(s) belonging to other applications on this system:
+
+{found}
+
+BlockRun now uses only its own wallet:
+
+ {new_address}
+
+Discovered wallets are never adopted automatically — one may belong to a
+different application, or have been planted to make you fund an address you
+do not control.
+
+If an address above is yours and holds your USDC, adopt it deliberately:
+
+ from blockrun_llm import import_solana_wallet
+ import_solana_wallet("")
+
+Your current wallet is backed up first. You can also set
+SOLANA_WALLET_KEY= for a single run without changing anything.
+"""
+
+
+def setup_agent_solana_wallet(silent: bool = False) -> "SolanaLLMClient":
+ """
+ Set up Solana wallet for agent use and return a SolanaLLMClient.
+
+ This is the entry point for Claude Code skills and other agent runtimes.
+ It auto-creates a Solana wallet if needed and prints address if new.
+
+ Args:
+ silent: If True, don't print welcome message (default: False)
+
+ Returns:
+ Configured SolanaLLMClient ready for use
+
+ Example:
+ from blockrun_llm import setup_agent_solana_wallet
+
+ client = setup_agent_solana_wallet()
+ response = client.chat("openai/gpt-5.2", "Hello!")
+ """
+ import sys
+
+ result = get_or_create_solana_wallet()
+
+ if result["is_new"]:
+ # Printed even when silent: `silent` suppresses the welcome message,
+ # and losing sight of a funded wallet is not something to stay quiet
+ # about.
+ notice = format_solana_wallet_migration_notice(str(result["address"]))
+ if notice:
+ print(notice, file=sys.stderr)
+
+ if not silent:
+ print(f"New Solana wallet created: {result['address']}", file=sys.stderr)
+
+ from .solana_client import SolanaLLMClient
+
+ return SolanaLLMClient(private_key=result["private_key"])
+
+
+def get_solana_usdc_balance(address: str, rpc_url: Optional[str] = None) -> float:
+ """
+ Get USDC-SPL balance for a Solana address.
+
+ Args:
+ address: Solana wallet address (base58)
+ rpc_url: Solana RPC endpoint (default: mainnet-beta)
+
+ Returns:
+ USDC balance as float (6 decimals)
+ """
+ import httpx
+
+ rpc = rpc_url or "https://api.mainnet-beta.solana.com"
+ # USDC mint on Solana mainnet
+ usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+
+ try:
+ resp = httpx.post(
+ rpc,
+ json={
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "getTokenAccountsByOwner",
+ "params": [
+ address,
+ {"mint": usdc_mint},
+ {"encoding": "jsonParsed"},
+ ],
+ },
+ timeout=10,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ accounts = data.get("result", {}).get("value", [])
+ if not accounts:
+ return 0.0
+
+ # Sum all USDC token accounts (usually just one)
+ total = 0.0
+ for acct in accounts:
+ info = acct.get("account", {}).get("data", {}).get("parsed", {}).get("info", {})
+ token_amount = info.get("tokenAmount", {})
+ total += float(token_amount.get("uiAmount", 0))
+ return total
+
+ except Exception:
+ return 0.0
+
+
+# QR code file paths for Solana
+SOLANA_QR_FILE = WALLET_DIR / "solana_qr.png"
+SOLANA_QR_ASCII_FILE = WALLET_DIR / "solana_qr.txt"
+
+
+def generate_solana_qr_ascii(address: str) -> str:
+ """
+ Generate ASCII QR code for Solana wallet funding.
+ Uses solana: URI scheme. Caches to ~/.blockrun/solana_qr.txt.
+
+ Args:
+ address: Solana wallet address (base58)
+
+ Returns:
+ ASCII art QR code string
+ """
+ solana_uri = f"solana:{address}"
+ cache_key = f"v1:{solana_uri}"
+
+ # Try cache
+ if SOLANA_QR_ASCII_FILE.exists():
+ try:
+ cached = SOLANA_QR_ASCII_FILE.read_text()
+ lines = cached.split("\n", 1)
+ if len(lines) == 2 and lines[0] == cache_key:
+ return lines[1]
+ except Exception:
+ pass
+
+ # Generate new QR
+ try:
+ import qrcode
+ from io import StringIO
+
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=1,
+ border=1,
+ )
+ qr.add_data(solana_uri)
+ qr.make(fit=True)
+
+ f = StringIO()
+ qr.print_ascii(out=f, invert=True)
+ qr_ascii = f.getvalue()
+
+ # Cache
+ try:
+ WALLET_DIR.mkdir(exist_ok=True)
+ SOLANA_QR_ASCII_FILE.write_text(f"{cache_key}\n{qr_ascii}")
+ except Exception:
+ pass
+
+ return qr_ascii
+
+ except ImportError:
+ return f"[QR code requires 'qrcode' package: pip install qrcode[pil]]\nAddress: {address}"
+
+
+def save_solana_wallet_qr(address: str, path: Optional[str] = None) -> str:
+ """
+ Save Solana QR code as PNG image.
+
+ Args:
+ address: Solana wallet address (base58)
+ path: Optional custom path (default: ~/.blockrun/solana_qr.png)
+
+ Returns:
+ Path to saved QR image, or empty string on failure
+ """
+ try:
+ import qrcode
+
+ solana_uri = f"solana:{address}"
+
+ qr = qrcode.QRCode(
+ version=4,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=10,
+ border=2,
+ )
+ qr.add_data(solana_uri)
+ qr.make(fit=True)
+
+ img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
+
+ save_path = Path(path) if path else SOLANA_QR_FILE
+ save_path.parent.mkdir(exist_ok=True)
+ img.save(str(save_path))
+
+ return str(save_path)
+
+ except ImportError:
+ return ""
+
+
+def open_solana_wallet_qr(address: str) -> str:
+ """
+ Generate Solana QR code and open it in the default image viewer.
+
+ Args:
+ address: Solana wallet address (base58)
+
+ Returns:
+ Path to saved QR image
+ """
+ import subprocess
+ import platform
+
+ qr_path = save_solana_wallet_qr(address)
+ if qr_path:
+ try:
+ if platform.system() == "Darwin":
+ subprocess.run(["open", qr_path], check=True)
+ elif platform.system() == "Windows":
+ subprocess.run(["start", qr_path], shell=True, check=True)
+ else:
+ subprocess.run(["xdg-open", qr_path], check=True)
+ except Exception:
+ pass
+ return qr_path
diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py
index 9c798eb..a9736f4 100644
--- a/blockrun_llm/validation.py
+++ b/blockrun_llm/validation.py
@@ -207,17 +207,20 @@ def validate_image_quality(quality: Optional[str]) -> None:
)
-# Client-side typo guard, NOT a model limit. The gateway already enforces the
-# real per-model ceiling and rejects with that model's own number, so anything
-# the SDK hardcodes here can only be wrong in one direction: too low.
+# Client-side typo guard, NOT a model limit.
#
-# This was 100000, and it silently capped every SDK caller below what models
-# actually support. Verified against the live gateway 2026-07-21 with the guard
-# bypassed: zai/glm-5.2 accepts 262144, and the entire 128000 class accepts
-# 128000 (claude-opus-4.8, claude-sonnet-5, claude-fable-5, gpt-5.6-sol/terra/
-# luna, gpt-5.5, gpt-5.4, gpt-5.3-codex, glm-5/5.1/5-turbo — 19 models, 19
-# accepted, zero rejections). Callers asking for those ceilings got a
-# ValueError that never reached the network and named a limit no provider set.
+# This was 100000, which sat below what models actually serve: zai/glm-5.2
+# serves 262144 and the common ceiling is 128000, so the SDK — not the model —
+# was the binding constraint, and callers got a ValueError naming a limit no
+# provider had set.
+#
+# The gateway does NOT reject an over-ceiling max_tokens. It silently clamps to
+# the model's ceiling and quotes payment for the clamped value (probed against
+# the live 402 leg 2026-07-21: opus-4.8 sent 262144 and 1000000 both quote the
+# 128000 price; gpt-5.2 sent 1e12 returns a quote, not a 400). So there is no
+# server-side rejection to fall back on — whatever passes here gets priced, and
+# anything above the model's ceiling is money spent on tokens you won't get.
+# ``LLMClient`` warns when it sees the gateway clamp; see ``_warn_if_clamped``.
#
# Keep a bound so an obvious mistake (1e9, a byte count, a timestamp) fails
# fast locally instead of becoming a payment quote. Set it far above any real
@@ -250,8 +253,10 @@ def validate_max_tokens(max_tokens: Optional[int]) -> None:
if max_tokens > MAX_TOKENS_SANITY_LIMIT:
raise ValueError(
f"max_tokens implausibly large (client-side sanity limit: "
- f"{MAX_TOKENS_SANITY_LIMIT}). This is not a model limit — the "
- f"gateway enforces the real per-model ceiling and reports it."
+ f"{MAX_TOKENS_SANITY_LIMIT}). This is not a model limit — no "
+ f"provider set it. Anything under it is sent to the gateway, "
+ f"which clamps to the model's own ceiling and charges for the "
+ f"clamped value rather than rejecting."
)
diff --git a/blockrun_llm/x402.py b/blockrun_llm/x402.py
index 4576519..97656a4 100644
--- a/blockrun_llm/x402.py
+++ b/blockrun_llm/x402.py
@@ -1,272 +1,272 @@
-"""
-x402 Payment Protocol v2 Implementation for BlockRun.
-
-This module handles creating signed payment payloads for the x402 v2 protocol.
-The private key is used ONLY for local signing and NEVER leaves the client.
-"""
-
-import json
-import time
-import base64
-import secrets
-from typing import Dict, Any, Optional
-from eth_account import Account
-from eth_account.messages import encode_typed_data
-
-
-# Chain and token constants for mainnet
-BASE_CHAIN_ID = 8453
-USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
-
-# Chain and token constants for testnet (Base Sepolia)
-BASE_SEPOLIA_CHAIN_ID = 84532
-USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
-
-
-# BlockRun's x402 builder code — the ERC-8021 Schema 2 service code (`s`) that
-# tags every payment this SDK signs as BlockRun-originated for on-chain
-# attribution. See https://docs.cdp.coinbase.com/x402/core-concepts/builder-codes
-BLOCKRUN_SERVICE_CODE = "blockrun"
-
-
-def with_builder_code_service_code(
- extensions: Optional[Dict[str, Any]],
-) -> Dict[str, Any]:
- """Merge BlockRun's service code (``s``) into the payload's ``builder-code``
- extension, preserving any app code (``a``) the server echoed back in its 402.
-
- The CDP facilitator reads ``builder-code.info.s`` and encodes it into the
- settlement calldata suffix — no CBOR/encoding happens client-side.
- """
- merged: Dict[str, Any] = dict(extensions or {})
- existing = dict(merged.get("builder-code") or {})
- info = dict(existing.get("info") or {})
- info["s"] = [BLOCKRUN_SERVICE_CODE]
- existing["info"] = info
- merged["builder-code"] = existing
- return merged
-
-
-def get_chain_config(network: str) -> tuple[int, str]:
- """
- Get chain ID and USDC contract address for a given network.
-
- Args:
- network: Network identifier in EIP-155 format (e.g., "eip155:8453" or "eip155:84532")
-
- Returns:
- Tuple of (chain_id, usdc_address)
- """
- if network == "eip155:84532" or network == "base-sepolia":
- return BASE_SEPOLIA_CHAIN_ID, USDC_BASE_SEPOLIA
- # Default to mainnet
- return BASE_CHAIN_ID, USDC_BASE
-
-
-def get_usdc_domain_name(network: str) -> str:
- """
- Get the EIP-712 domain name for USDC on a given network.
-
- Mainnet USDC uses "USD Coin", testnet USDC uses "USDC".
-
- Args:
- network: Network identifier in EIP-155 format
-
- Returns:
- The EIP-712 domain name for signing
- """
- if network == "eip155:84532" or network == "base-sepolia":
- return "USDC"
- return "USD Coin"
-
-
-def create_nonce() -> str:
- """Generate a random bytes32 nonce."""
- return "0x" + secrets.token_hex(32)
-
-
-def create_payment_payload(
- account: Account,
- recipient: str,
- amount: str, # In micro USDC (6 decimals)
- network: str = "eip155:8453",
- resource_url: str = "https://blockrun.ai/api/v1/chat/completions",
- resource_description: str = "BlockRun AI API call",
- max_timeout_seconds: int = 300,
- extra: Optional[Dict[str, str]] = None,
- extensions: Optional[Dict[str, Any]] = None,
- asset: Optional[str] = None,
-) -> str:
- """
- Create a signed x402 v2 payment payload.
-
- This uses EIP-712 typed data signing to create a payment authorization
- that the CDP facilitator can verify and settle.
-
- Args:
- account: eth-account Account instance
- recipient: Payment recipient address (checksummed)
- amount: Amount in micro USDC (6 decimals, e.g., "1000" = $0.001)
- network: Network identifier (e.g., "eip155:8453" for Base mainnet, "eip155:84532" for Base Sepolia)
- resource_url: URL of the resource being accessed
- resource_description: Description of the resource
- max_timeout_seconds: Max timeout for the payment (default: 300)
- extra: Extra info for USDC domain (name, version)
- asset: USDC contract address (optional, derived from network if not provided)
-
- Returns:
- Base64-encoded signed payment payload
- """
- # Current timestamp
- now = int(time.time())
- valid_after = now - 600 # 10 minutes before (allows for clock skew)
- valid_before = now + max_timeout_seconds
-
- # Generate random nonce
- nonce = create_nonce()
-
- # Get chain config based on network
- chain_id, default_usdc = get_chain_config(network)
-
- # Use provided asset address or default for the network
- usdc_address = asset or default_usdc
-
- # EIP-712 domain for USDC (mainnet or testnet based on network)
- default_domain_name = get_usdc_domain_name(network)
- domain = {
- "name": extra.get("name", default_domain_name) if extra else default_domain_name,
- "version": extra.get("version", "2") if extra else "2",
- "chainId": chain_id,
- "verifyingContract": usdc_address,
- }
-
- # EIP-712 types for TransferWithAuthorization
- types = {
- "TransferWithAuthorization": [
- {"name": "from", "type": "address"},
- {"name": "to", "type": "address"},
- {"name": "value", "type": "uint256"},
- {"name": "validAfter", "type": "uint256"},
- {"name": "validBefore", "type": "uint256"},
- {"name": "nonce", "type": "bytes32"},
- ],
- }
-
- # Message to sign
- message = {
- "from": account.address,
- "to": recipient,
- "value": int(amount),
- "validAfter": valid_after,
- "validBefore": valid_before,
- "nonce": bytes.fromhex(nonce[2:]), # Remove 0x prefix
- }
-
- # Sign using EIP-712
- signable = encode_typed_data(domain, types, message)
- signed = account.sign_message(signable)
-
- # Create x402 v2 payment payload
- payment_data = {
- "x402Version": 2,
- "resource": {
- "url": resource_url,
- "description": resource_description,
- "mimeType": "application/json",
- },
- "accepted": {
- "scheme": "exact",
- "network": network,
- "amount": amount,
- "asset": usdc_address,
- "payTo": recipient,
- "maxTimeoutSeconds": max_timeout_seconds,
- "extra": extra or {"name": default_domain_name, "version": "2"},
- },
- "payload": {
- "signature": (
- "0x" + signed.signature.hex()
- if not signed.signature.hex().startswith("0x")
- else signed.signature.hex()
- ),
- "authorization": {
- "from": account.address,
- "to": recipient,
- "value": amount,
- "validAfter": str(valid_after),
- "validBefore": str(valid_before),
- "nonce": nonce,
- },
- },
- "extensions": with_builder_code_service_code(extensions),
- }
-
- # Encode as base64
- return base64.b64encode(json.dumps(payment_data).encode()).decode()
-
-
-def parse_payment_required(header_value: str) -> Dict[str, Any]:
- """
- Parse the X-Payment-Required header from a 402 response.
-
- Args:
- header_value: Base64-encoded payment requirements
-
- Returns:
- Decoded payment requirements dict
- """
- try:
- decoded = base64.b64decode(header_value)
- return json.loads(decoded)
- except Exception:
- # Don't expose internal error details
- raise ValueError("Failed to parse payment required header: invalid format")
-
-
-def extract_payment_details(payment_required: Dict[str, Any]) -> Dict[str, Any]:
- """
- Extract payment details from parsed payment required response.
-
- Supports both v1 and v2 formats.
-
- Args:
- payment_required: Parsed payment required dict
-
- Returns:
- Dict with amount, recipient, network, asset, and extra info
- """
- accepts = payment_required.get("accepts", [])
- if not accepts:
- raise ValueError("No payment options in payment required response")
-
- # Take the first option
- option = accepts[0]
-
- # Support both v1 (maxAmountRequired) and v2 (amount) formats
- amount = option.get("amount") or option.get("maxAmountRequired")
- if not amount:
- raise ValueError("No amount found in payment requirements")
-
- return {
- "amount": amount,
- "recipient": option.get("payTo"),
- "network": option.get("network"),
- "asset": option.get("asset"),
- "scheme": option.get("scheme"),
- "maxTimeoutSeconds": option.get("maxTimeoutSeconds", 300),
- "extra": option.get("extra"),
- "resource": payment_required.get("resource"),
- }
-
-
-# ============================================================
-# Solana x402 Payment — delegated to official x402 SDK
-# ============================================================
-# The Solana payment implementation has been replaced by the
-# official x402 Python SDK (pip install x402[svm]).
-# See solana_client.py for usage.
-
-
-def is_solana_network(network: str) -> bool:
- """Check if a network string represents Solana."""
- return network.startswith("solana:")
+"""
+x402 Payment Protocol v2 Implementation for BlockRun.
+
+This module handles creating signed payment payloads for the x402 v2 protocol.
+The private key is used ONLY for local signing and NEVER leaves the client.
+"""
+
+import json
+import time
+import base64
+import secrets
+from typing import Dict, Any, Optional
+from eth_account import Account
+from eth_account.messages import encode_typed_data
+
+
+# Chain and token constants for mainnet
+BASE_CHAIN_ID = 8453
+USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
+
+# Chain and token constants for testnet (Base Sepolia)
+BASE_SEPOLIA_CHAIN_ID = 84532
+USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
+
+
+# BlockRun's x402 builder code — the ERC-8021 Schema 2 service code (`s`) that
+# tags every payment this SDK signs as BlockRun-originated for on-chain
+# attribution. See https://docs.cdp.coinbase.com/x402/core-concepts/builder-codes
+BLOCKRUN_SERVICE_CODE = "blockrun"
+
+
+def with_builder_code_service_code(
+ extensions: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+ """Merge BlockRun's service code (``s``) into the payload's ``builder-code``
+ extension, preserving any app code (``a``) the server echoed back in its 402.
+
+ The CDP facilitator reads ``builder-code.info.s`` and encodes it into the
+ settlement calldata suffix — no CBOR/encoding happens client-side.
+ """
+ merged: Dict[str, Any] = dict(extensions or {})
+ existing = dict(merged.get("builder-code") or {})
+ info = dict(existing.get("info") or {})
+ info["s"] = [BLOCKRUN_SERVICE_CODE]
+ existing["info"] = info
+ merged["builder-code"] = existing
+ return merged
+
+
+def get_chain_config(network: str) -> tuple[int, str]:
+ """
+ Get chain ID and USDC contract address for a given network.
+
+ Args:
+ network: Network identifier in EIP-155 format (e.g., "eip155:8453" or "eip155:84532")
+
+ Returns:
+ Tuple of (chain_id, usdc_address)
+ """
+ if network == "eip155:84532" or network == "base-sepolia":
+ return BASE_SEPOLIA_CHAIN_ID, USDC_BASE_SEPOLIA
+ # Default to mainnet
+ return BASE_CHAIN_ID, USDC_BASE
+
+
+def get_usdc_domain_name(network: str) -> str:
+ """
+ Get the EIP-712 domain name for USDC on a given network.
+
+ Mainnet USDC uses "USD Coin", testnet USDC uses "USDC".
+
+ Args:
+ network: Network identifier in EIP-155 format
+
+ Returns:
+ The EIP-712 domain name for signing
+ """
+ if network == "eip155:84532" or network == "base-sepolia":
+ return "USDC"
+ return "USD Coin"
+
+
+def create_nonce() -> str:
+ """Generate a random bytes32 nonce."""
+ return "0x" + secrets.token_hex(32)
+
+
+def create_payment_payload(
+ account: Account,
+ recipient: str,
+ amount: str, # In micro USDC (6 decimals)
+ network: str = "eip155:8453",
+ resource_url: str = "https://blockrun.ai/api/v1/chat/completions",
+ resource_description: str = "BlockRun AI API call",
+ max_timeout_seconds: int = 300,
+ extra: Optional[Dict[str, str]] = None,
+ extensions: Optional[Dict[str, Any]] = None,
+ asset: Optional[str] = None,
+) -> str:
+ """
+ Create a signed x402 v2 payment payload.
+
+ This uses EIP-712 typed data signing to create a payment authorization
+ that the CDP facilitator can verify and settle.
+
+ Args:
+ account: eth-account Account instance
+ recipient: Payment recipient address (checksummed)
+ amount: Amount in micro USDC (6 decimals, e.g., "1000" = $0.001)
+ network: Network identifier (e.g., "eip155:8453" for Base mainnet, "eip155:84532" for Base Sepolia)
+ resource_url: URL of the resource being accessed
+ resource_description: Description of the resource
+ max_timeout_seconds: Max timeout for the payment (default: 300)
+ extra: Extra info for USDC domain (name, version)
+ asset: USDC contract address (optional, derived from network if not provided)
+
+ Returns:
+ Base64-encoded signed payment payload
+ """
+ # Current timestamp
+ now = int(time.time())
+ valid_after = now - 600 # 10 minutes before (allows for clock skew)
+ valid_before = now + max_timeout_seconds
+
+ # Generate random nonce
+ nonce = create_nonce()
+
+ # Get chain config based on network
+ chain_id, default_usdc = get_chain_config(network)
+
+ # Use provided asset address or default for the network
+ usdc_address = asset or default_usdc
+
+ # EIP-712 domain for USDC (mainnet or testnet based on network)
+ default_domain_name = get_usdc_domain_name(network)
+ domain = {
+ "name": extra.get("name", default_domain_name) if extra else default_domain_name,
+ "version": extra.get("version", "2") if extra else "2",
+ "chainId": chain_id,
+ "verifyingContract": usdc_address,
+ }
+
+ # EIP-712 types for TransferWithAuthorization
+ types = {
+ "TransferWithAuthorization": [
+ {"name": "from", "type": "address"},
+ {"name": "to", "type": "address"},
+ {"name": "value", "type": "uint256"},
+ {"name": "validAfter", "type": "uint256"},
+ {"name": "validBefore", "type": "uint256"},
+ {"name": "nonce", "type": "bytes32"},
+ ],
+ }
+
+ # Message to sign
+ message = {
+ "from": account.address,
+ "to": recipient,
+ "value": int(amount),
+ "validAfter": valid_after,
+ "validBefore": valid_before,
+ "nonce": bytes.fromhex(nonce[2:]), # Remove 0x prefix
+ }
+
+ # Sign using EIP-712
+ signable = encode_typed_data(domain, types, message)
+ signed = account.sign_message(signable)
+
+ # Create x402 v2 payment payload
+ payment_data = {
+ "x402Version": 2,
+ "resource": {
+ "url": resource_url,
+ "description": resource_description,
+ "mimeType": "application/json",
+ },
+ "accepted": {
+ "scheme": "exact",
+ "network": network,
+ "amount": amount,
+ "asset": usdc_address,
+ "payTo": recipient,
+ "maxTimeoutSeconds": max_timeout_seconds,
+ "extra": extra or {"name": default_domain_name, "version": "2"},
+ },
+ "payload": {
+ "signature": (
+ "0x" + signed.signature.hex()
+ if not signed.signature.hex().startswith("0x")
+ else signed.signature.hex()
+ ),
+ "authorization": {
+ "from": account.address,
+ "to": recipient,
+ "value": amount,
+ "validAfter": str(valid_after),
+ "validBefore": str(valid_before),
+ "nonce": nonce,
+ },
+ },
+ "extensions": with_builder_code_service_code(extensions),
+ }
+
+ # Encode as base64
+ return base64.b64encode(json.dumps(payment_data).encode()).decode()
+
+
+def parse_payment_required(header_value: str) -> Dict[str, Any]:
+ """
+ Parse the X-Payment-Required header from a 402 response.
+
+ Args:
+ header_value: Base64-encoded payment requirements
+
+ Returns:
+ Decoded payment requirements dict
+ """
+ try:
+ decoded = base64.b64decode(header_value)
+ return json.loads(decoded)
+ except Exception:
+ # Don't expose internal error details
+ raise ValueError("Failed to parse payment required header: invalid format")
+
+
+def extract_payment_details(payment_required: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Extract payment details from parsed payment required response.
+
+ Supports both v1 and v2 formats.
+
+ Args:
+ payment_required: Parsed payment required dict
+
+ Returns:
+ Dict with amount, recipient, network, asset, and extra info
+ """
+ accepts = payment_required.get("accepts", [])
+ if not accepts:
+ raise ValueError("No payment options in payment required response")
+
+ # Take the first option
+ option = accepts[0]
+
+ # Support both v1 (maxAmountRequired) and v2 (amount) formats
+ amount = option.get("amount") or option.get("maxAmountRequired")
+ if not amount:
+ raise ValueError("No amount found in payment requirements")
+
+ return {
+ "amount": amount,
+ "recipient": option.get("payTo"),
+ "network": option.get("network"),
+ "asset": option.get("asset"),
+ "scheme": option.get("scheme"),
+ "maxTimeoutSeconds": option.get("maxTimeoutSeconds", 300),
+ "extra": option.get("extra"),
+ "resource": payment_required.get("resource"),
+ }
+
+
+# ============================================================
+# Solana x402 Payment — delegated to official x402 SDK
+# ============================================================
+# The Solana payment implementation has been replaced by the
+# official x402 Python SDK (pip install x402[svm]).
+# See solana_client.py for usage.
+
+
+def is_solana_network(network: str) -> bool:
+ """Check if a network string represents Solana."""
+ return network.startswith("solana:")
diff --git a/pyproject.toml b/pyproject.toml
index 9d5788c..83b9bd8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "blockrun-llm"
-version = "1.8.1"
+version = "1.8.2"
description = "BlockRun SDK - Pay-per-request AI (LLM, Image, Video, Music, Voice) via x402 on Base and Solana"
readme = "README.md"
license = "MIT"
diff --git a/pytest.ini b/pytest.ini
index 392c6ba..81f76ce 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,16 +1,16 @@
-[pytest]
-testpaths = tests
-python_files = test_*.py
-python_classes = Test*
-python_functions = test_*
-addopts =
- -v
- --strict-markers
- --tb=short
-markers =
- integration: Integration tests requiring API access and funded wallet
- unit: Unit tests (run by default)
- asyncio: Async tests using pytest-asyncio
-
-# Async test configuration
-asyncio_mode = auto
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+addopts =
+ -v
+ --strict-markers
+ --tb=short
+markers =
+ integration: Integration tests requiring API access and funded wallet
+ unit: Unit tests (run by default)
+ asyncio: Async tests using pytest-asyncio
+
+# Async test configuration
+asyncio_mode = auto
diff --git a/tests/__init__.py b/tests/__init__.py
index ec7d357..005b3b8 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1 +1 @@
-"""Tests for BlockRun LLM SDK."""
+"""Tests for BlockRun LLM SDK."""
diff --git a/tests/helpers.py b/tests/helpers.py
index 0f15580..c394c78 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,151 +1,151 @@
-"""
-Test utilities and mock builders for BlockRun LLM SDK tests.
-"""
-
-import json
-import base64
-from typing import Dict, Any, Optional
-from eth_account import Account
-
-# Test private key (DO NOT use in production)
-# This is a well-known test key from Hardhat/Foundry
-TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
-
-# Test account derived from TEST_PRIVATE_KEY
-# Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
-TEST_ACCOUNT = Account.from_key(TEST_PRIVATE_KEY)
-
-# Test recipient address for payment mocks
-TEST_RECIPIENT = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
-
-
-def build_payment_required_response(
- amount: str = "1000000",
- recipient: str = TEST_RECIPIENT,
- network: str = "eip155:8453",
- resource: Optional[Dict[str, str]] = None,
-) -> str:
- """Build a mock 402 Payment Required response."""
- payment_required = {
- "x402Version": 2,
- "accepts": [
- {
- "scheme": "exact",
- "network": network,
- "amount": amount,
- "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
- "payTo": recipient,
- "maxTimeoutSeconds": 300,
- "extra": {"name": "USD Coin", "version": "2"},
- }
- ],
- "resource": resource
- or {
- "url": "https://api.blockrun.ai/v1/chat/completions",
- "description": "BlockRun AI API call",
- },
- }
-
- return base64.b64encode(json.dumps(payment_required).encode()).decode()
-
-
-def build_chat_response(
- content: str = "This is a test response.",
- model: str = "gpt-5.2",
- prompt_tokens: int = 10,
- completion_tokens: int = 20,
-) -> Dict[str, Any]:
- """Build a mock successful chat response."""
- return {
- "id": "chatcmpl-test123",
- "object": "chat.completion",
- "created": 1234567890,
- "model": model,
- "choices": [
- {
- "index": 0,
- "message": {"role": "assistant", "content": content},
- "finish_reason": "stop",
- }
- ],
- "usage": {
- "prompt_tokens": prompt_tokens,
- "completion_tokens": completion_tokens,
- "total_tokens": prompt_tokens + completion_tokens,
- },
- }
-
-
-def build_error_response(
- error: str = "Test error message",
- code: str = "test_error",
- include_sensitive: bool = True,
-) -> Dict[str, Any]:
- """Build a mock error response."""
- response = {"error": error, "code": code}
-
- if include_sensitive:
- # These should be filtered out by sanitization
- response.update(
- {
- "internal_stack": "/var/app/handler.py:123",
- "api_key": "secret_key_should_be_filtered",
- "database_url": "postgres://user:pass@host/db",
- }
- )
-
- return response
-
-
-def build_models_response() -> Dict[str, Any]:
- """Build a mock models list response."""
- return {
- "data": [
- {
- "id": "openai/gpt-5.2",
- "provider": "openai",
- "name": "GPT-5.2",
- "inputPrice": 2.5,
- "outputPrice": 10.0,
- },
- {
- "id": "anthropic/claude-sonnet-4.6",
- "provider": "anthropic",
- "name": "Claude Sonnet 4.6",
- "inputPrice": 3.0,
- "outputPrice": 15.0,
- },
- {
- "id": "google/gemini-2.5-flash",
- "provider": "google",
- "name": "Gemini 2.5 Flash",
- "inputPrice": 0.15,
- "outputPrice": 0.6,
- },
- ]
- }
-
-
-class MockResponse:
- """Mock HTTP response for testing."""
-
- def __init__(
- self,
- status_code: int,
- json_data: Optional[Dict[str, Any]] = None,
- text_data: Optional[str] = None,
- headers: Optional[Dict[str, str]] = None,
- ):
- self.status_code = status_code
- self._json_data = json_data
- self._text_data = text_data or (json.dumps(json_data) if json_data else "")
- self.headers = headers or {}
-
- def json(self) -> Dict[str, Any]:
- if self._json_data is None:
- raise ValueError("No JSON data available")
- return self._json_data
-
- @property
- def text(self) -> str:
- return self._text_data
+"""
+Test utilities and mock builders for BlockRun LLM SDK tests.
+"""
+
+import json
+import base64
+from typing import Dict, Any, Optional
+from eth_account import Account
+
+# Test private key (DO NOT use in production)
+# This is a well-known test key from Hardhat/Foundry
+TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
+
+# Test account derived from TEST_PRIVATE_KEY
+# Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
+TEST_ACCOUNT = Account.from_key(TEST_PRIVATE_KEY)
+
+# Test recipient address for payment mocks
+TEST_RECIPIENT = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
+
+
+def build_payment_required_response(
+ amount: str = "1000000",
+ recipient: str = TEST_RECIPIENT,
+ network: str = "eip155:8453",
+ resource: Optional[Dict[str, str]] = None,
+) -> str:
+ """Build a mock 402 Payment Required response."""
+ payment_required = {
+ "x402Version": 2,
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": network,
+ "amount": amount,
+ "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "payTo": recipient,
+ "maxTimeoutSeconds": 300,
+ "extra": {"name": "USD Coin", "version": "2"},
+ }
+ ],
+ "resource": resource
+ or {
+ "url": "https://api.blockrun.ai/v1/chat/completions",
+ "description": "BlockRun AI API call",
+ },
+ }
+
+ return base64.b64encode(json.dumps(payment_required).encode()).decode()
+
+
+def build_chat_response(
+ content: str = "This is a test response.",
+ model: str = "gpt-5.2",
+ prompt_tokens: int = 10,
+ completion_tokens: int = 20,
+) -> Dict[str, Any]:
+ """Build a mock successful chat response."""
+ return {
+ "id": "chatcmpl-test123",
+ "object": "chat.completion",
+ "created": 1234567890,
+ "model": model,
+ "choices": [
+ {
+ "index": 0,
+ "message": {"role": "assistant", "content": content},
+ "finish_reason": "stop",
+ }
+ ],
+ "usage": {
+ "prompt_tokens": prompt_tokens,
+ "completion_tokens": completion_tokens,
+ "total_tokens": prompt_tokens + completion_tokens,
+ },
+ }
+
+
+def build_error_response(
+ error: str = "Test error message",
+ code: str = "test_error",
+ include_sensitive: bool = True,
+) -> Dict[str, Any]:
+ """Build a mock error response."""
+ response = {"error": error, "code": code}
+
+ if include_sensitive:
+ # These should be filtered out by sanitization
+ response.update(
+ {
+ "internal_stack": "/var/app/handler.py:123",
+ "api_key": "secret_key_should_be_filtered",
+ "database_url": "postgres://user:pass@host/db",
+ }
+ )
+
+ return response
+
+
+def build_models_response() -> Dict[str, Any]:
+ """Build a mock models list response."""
+ return {
+ "data": [
+ {
+ "id": "openai/gpt-5.2",
+ "provider": "openai",
+ "name": "GPT-5.2",
+ "inputPrice": 2.5,
+ "outputPrice": 10.0,
+ },
+ {
+ "id": "anthropic/claude-sonnet-4.6",
+ "provider": "anthropic",
+ "name": "Claude Sonnet 4.6",
+ "inputPrice": 3.0,
+ "outputPrice": 15.0,
+ },
+ {
+ "id": "google/gemini-2.5-flash",
+ "provider": "google",
+ "name": "Gemini 2.5 Flash",
+ "inputPrice": 0.15,
+ "outputPrice": 0.6,
+ },
+ ]
+ }
+
+
+class MockResponse:
+ """Mock HTTP response for testing."""
+
+ def __init__(
+ self,
+ status_code: int,
+ json_data: Optional[Dict[str, Any]] = None,
+ text_data: Optional[str] = None,
+ headers: Optional[Dict[str, str]] = None,
+ ):
+ self.status_code = status_code
+ self._json_data = json_data
+ self._text_data = text_data or (json.dumps(json_data) if json_data else "")
+ self.headers = headers or {}
+
+ def json(self) -> Dict[str, Any]:
+ if self._json_data is None:
+ raise ValueError("No JSON data available")
+ return self._json_data
+
+ @property
+ def text(self) -> str:
+ return self._text_data
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
index 98e40eb..9b84820 100644
--- a/tests/integration/__init__.py
+++ b/tests/integration/__init__.py
@@ -1 +1 @@
-"""Integration tests for BlockRun LLM SDK."""
+"""Integration tests for BlockRun LLM SDK."""
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 082ccb4..bf40f6f 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -1,26 +1,26 @@
-"""Pytest configuration for integration tests."""
-
-import os
-import pytest
-
-
-def pytest_configure(config):
- """Configure pytest with custom markers."""
- config.addinivalue_line(
- "markers", "integration: Integration tests requiring funded wallet and API access"
- )
-
-
-@pytest.fixture(scope="session")
-def wallet_private_key():
- """Get wallet private key from environment variable.
-
- Returns None if not set, which will cause integration tests to be skipped.
- """
- return os.environ.get("BASE_CHAIN_WALLET_KEY")
-
-
-@pytest.fixture(scope="session")
-def production_api_url():
- """Get production API URL."""
- return "https://blockrun.ai/api"
+"""Pytest configuration for integration tests."""
+
+import os
+import pytest
+
+
+def pytest_configure(config):
+ """Configure pytest with custom markers."""
+ config.addinivalue_line(
+ "markers", "integration: Integration tests requiring funded wallet and API access"
+ )
+
+
+@pytest.fixture(scope="session")
+def wallet_private_key():
+ """Get wallet private key from environment variable.
+
+ Returns None if not set, which will cause integration tests to be skipped.
+ """
+ return os.environ.get("BASE_CHAIN_WALLET_KEY")
+
+
+@pytest.fixture(scope="session")
+def production_api_url():
+ """Get production API URL."""
+ return "https://blockrun.ai/api"
diff --git a/tests/integration/test_production_api.py b/tests/integration/test_production_api.py
index 07e5d9d..88feba2 100644
--- a/tests/integration/test_production_api.py
+++ b/tests/integration/test_production_api.py
@@ -1,315 +1,315 @@
-"""Integration tests for BlockRun LLM SDK against production API.
-
-Requirements:
-- BASE_CHAIN_WALLET_KEY environment variable with funded Base wallet
-- Minimum $1 USDC on Base chain
-- Estimated cost per test run: ~$0.05
-
-Run with: pytest tests/integration
-Skip if no wallet: Tests will be skipped if BASE_CHAIN_WALLET_KEY not set
-"""
-
-import asyncio
-import os
-import time
-
-import pytest
-from blockrun_llm import LLMClient, AsyncLLMClient
-
-WALLET_KEY = os.environ.get("BASE_CHAIN_WALLET_KEY")
-PRODUCTION_API = "https://blockrun.ai/api"
-
-# Skip all tests if no wallet key configured
-pytestmark = pytest.mark.skipif(
- not WALLET_KEY, reason="BASE_CHAIN_WALLET_KEY environment variable not set"
-)
-
-
-class TestProductionAPISync:
- """Integration tests for synchronous LLMClient against production API."""
-
- @pytest.fixture(scope="class")
- def client(self):
- """Create LLMClient instance for testing."""
- if not WALLET_KEY:
- pytest.skip("BASE_CHAIN_WALLET_KEY not set")
-
- client = LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API)
-
- print("\n🧪 Running sync integration tests against production API")
- print(f" Wallet: {client.get_wallet_address()}")
- print(f" API: {PRODUCTION_API}")
- print(" Estimated cost: ~$0.05\n")
-
- return client
-
- def test_list_models(self, client):
- """Should list available models from production API."""
- models = client.list_models()
-
- assert models is not None
- assert isinstance(models, list)
- assert len(models) > 0
-
- # Verify model structure
- first_model = models[0]
- assert "id" in first_model
- assert "provider" in first_model
- assert "inputPrice" in first_model
- assert "outputPrice" in first_model
-
- print(f" ✓ Found {len(models)} models")
-
- # Respect rate limits
- time.sleep(2)
-
- def test_simple_chat_request(self, client):
- """Should complete a simple chat request."""
- # Use cheapest model for testing
- response = client.chat(
- "google/gemini-2.5-flash-lite",
- [{"role": "user", "content": "Say 'test passed' and nothing else"}],
- )
-
- assert response is not None
- assert isinstance(response, str)
- assert "test passed" in response.lower()
-
- print(f" ✓ Chat response: {response[:50]}...")
-
- time.sleep(2)
-
- def test_chat_completion_with_usage_stats(self, client):
- """Should return chat completion with usage stats."""
- completion = client.chat_completion(
- "google/gemini-2.5-flash-lite",
- [{"role": "user", "content": "Count to 5"}],
- max_tokens=50,
- )
-
- assert completion is not None
- assert "choices" in completion
- assert len(completion["choices"]) > 0
- assert "message" in completion["choices"][0]
- assert "content" in completion["choices"][0]["message"]
- assert completion["choices"][0]["message"]["content"]
-
- # Verify usage stats
- assert "usage" in completion
- assert completion["usage"]["prompt_tokens"] > 0
- assert completion["usage"]["completion_tokens"] > 0
- assert completion["usage"]["total_tokens"] > 0
-
- print(f" ✓ Completion with usage: {completion['usage']}")
-
- time.sleep(2)
-
- def test_payment_flow_end_to_end(self, client):
- """Should handle 402 payment flow end-to-end.
-
- This test verifies the full x402 payment protocol:
- 1. Request to API
- 2. Receive 402 with payment required
- 3. Create payment payload with EIP-712 signature
- 4. Retry with payment receipt
- 5. Receive successful response
- """
- response = client.chat(
- "google/gemini-2.5-flash-lite", [{"role": "user", "content": "What is 2+2?"}]
- )
-
- # If we got a response, the payment flow succeeded
- assert response is not None
- assert isinstance(response, str)
- assert response
-
- print(" ✓ Payment flow successful, response received")
-
- time.sleep(2)
-
-
-class TestProductionAPIAsync:
- """Integration tests for asynchronous AsyncLLMClient against production API."""
-
- @pytest.fixture(scope="class")
- async def async_client(self):
- """Create AsyncLLMClient instance for testing."""
- if not WALLET_KEY:
- pytest.skip("BASE_CHAIN_WALLET_KEY not set")
-
- client = AsyncLLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API)
-
- print("\n🧪 Running async integration tests against production API")
- print(f" Wallet: {client.get_wallet_address()}")
- print(f" API: {PRODUCTION_API}")
- print(" Estimated cost: ~$0.05\n")
-
- return client
-
- @pytest.mark.asyncio
- async def test_async_list_models(self, async_client):
- """Should list available models asynchronously."""
- models = await async_client.list_models()
-
- assert models is not None
- assert isinstance(models, list)
- assert len(models) > 0
-
- print(f" ✓ Async: Found {len(models)} models")
-
- await asyncio.sleep(2)
-
- @pytest.mark.asyncio
- async def test_async_simple_chat(self, async_client):
- """Should complete a simple chat request asynchronously."""
- response = await async_client.chat(
- "google/gemini-2.5-flash-lite",
- [{"role": "user", "content": "Say 'async test passed' and nothing else"}],
- )
-
- assert response is not None
- assert isinstance(response, str)
- assert "test passed" in response.lower()
-
- print(f" ✓ Async chat response: {response[:50]}...")
-
- await asyncio.sleep(2)
-
- @pytest.mark.asyncio
- async def test_async_chat_completion(self, async_client):
- """Should return chat completion with usage stats asynchronously."""
- completion = await async_client.chat_completion(
- "google/gemini-2.5-flash-lite",
- [{"role": "user", "content": "Count to 5"}],
- max_tokens=50,
- )
-
- assert completion is not None
- assert "choices" in completion
- assert len(completion["choices"]) > 0
- assert "usage" in completion
- assert completion["usage"]["total_tokens"] > 0
-
- print(f" ✓ Async completion with usage: {completion['usage']}")
-
- await asyncio.sleep(2)
-
-
-class TestProductionAPIErrorHandling:
- """Integration tests for error handling against production API."""
-
- @pytest.fixture(scope="class")
- def client(self):
- """Create LLMClient instance for testing."""
- if not WALLET_KEY:
- pytest.skip("BASE_CHAIN_WALLET_KEY not set")
-
- return LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API)
-
- def test_invalid_model_error(self, client):
- """Should handle invalid model error gracefully."""
- from blockrun_llm import APIError
-
- with pytest.raises(APIError):
- client.chat(
- "invalid-model-that-does-not-exist",
- [{"role": "user", "content": "test"}],
- )
-
- print(" ✓ Invalid model error handled correctly")
-
- time.sleep(2)
-
- def test_error_response_sanitization(self, client):
- """Should sanitize error responses."""
- from blockrun_llm import APIError
-
- try:
- client.chat("invalid-model", [{"role": "user", "content": "test"}])
- pytest.fail("Should have raised APIError")
- except APIError as e:
- # Error should be sanitized (no internal stack traces, API keys, etc.)
- assert e.message is not None
- assert "/var/" not in str(e.message)
- assert (
- "internal" not in str(e.message).lower() or "internal" in str(e.message).lower()
- ) # Allow "internal" in error message but not internal paths
- assert "stack" not in str(e.message).lower()
-
- print(" ✓ Error response properly sanitized")
-
- time.sleep(2)
-
-
-# =============================================================================
-# Solana + Exa Integration Tests
-# =============================================================================
-
-SOLANA_WALLET_KEY = os.environ.get("SOLANA_WALLET_KEY")
-SOLANA_API = "https://sol.blockrun.ai/api"
-
-
-class TestSolanaExa:
- """Integration tests for Exa web search via SolanaLLMClient."""
-
- @pytest.fixture(scope="class")
- def client(self):
- if not SOLANA_WALLET_KEY:
- pytest.skip("SOLANA_WALLET_KEY not set")
- from blockrun_llm import SolanaLLMClient
-
- c = SolanaLLMClient(private_key=SOLANA_WALLET_KEY, api_url=SOLANA_API)
- print("\n🧪 Running Solana/Exa integration tests against sol.blockrun.ai")
- print(f" Wallet: {c.get_wallet_address()}")
- print(" Estimated cost: ~$0.04\n")
- return c
-
- def test_exa_search(self, client):
- """exa_search returns results with title/url fields."""
- result = client.exa_search("latest AI safety research", numResults=3)
- assert "results" in result, f"Expected 'results' key, got: {list(result.keys())}"
- assert len(result["results"]) > 0
- first = result["results"][0]
- assert "url" in first or "title" in first
- cost = client.get_spending()["total_usd"]
- assert 0.009 <= cost <= 0.011, f"Expected ~$0.01 cost, got {cost}"
- print(f" ✓ exa_search: {len(result['results'])} results, cost=${cost:.4f}")
- time.sleep(1)
-
- def test_exa_find_similar(self, client):
- """exa_find_similar returns semantically similar pages."""
- result = client.exa_find_similar("https://openai.com/research/gpt-4", numResults=3)
- assert "results" in result
- assert len(result["results"]) > 0
- print(f" ✓ exa_find_similar: {len(result['results'])} results")
- time.sleep(1)
-
- def test_exa_contents(self, client):
- """exa_contents extracts text from a URL, priced per URL."""
- result = client.exa_contents(["https://www.anthropic.com/research"])
- assert result is not None
- assert isinstance(result, dict)
- print(" ✓ exa_contents: response received")
- time.sleep(1)
-
- def test_exa_answer(self, client):
- """exa_answer returns an AI-generated answer from live web."""
- result = client.exa_answer("What is Anthropic Claude?")
- assert result is not None
- assert isinstance(result, dict)
- print(" ✓ exa_answer: response received")
- time.sleep(1)
-
- def test_exa_generic_proxy(self, client):
- """exa() generic proxy works for any endpoint."""
- result = client.exa("search", {"query": "blockrun.ai", "numResults": 2})
- assert "results" in result
- print(f" ✓ exa() generic: {len(result['results'])} results")
- time.sleep(1)
-
- def test_exa_spending_tracked(self, client):
- """Session spending is tracked across Exa calls."""
- spending = client.get_spending()
- assert spending["total_usd"] > 0
- assert spending["calls"] >= 3
- print(f" ✓ Spending tracked: ${spending['total_usd']:.4f} over {spending['calls']} calls")
+"""Integration tests for BlockRun LLM SDK against production API.
+
+Requirements:
+- BASE_CHAIN_WALLET_KEY environment variable with funded Base wallet
+- Minimum $1 USDC on Base chain
+- Estimated cost per test run: ~$0.05
+
+Run with: pytest tests/integration
+Skip if no wallet: Tests will be skipped if BASE_CHAIN_WALLET_KEY not set
+"""
+
+import asyncio
+import os
+import time
+
+import pytest
+from blockrun_llm import LLMClient, AsyncLLMClient
+
+WALLET_KEY = os.environ.get("BASE_CHAIN_WALLET_KEY")
+PRODUCTION_API = "https://blockrun.ai/api"
+
+# Skip all tests if no wallet key configured
+pytestmark = pytest.mark.skipif(
+ not WALLET_KEY, reason="BASE_CHAIN_WALLET_KEY environment variable not set"
+)
+
+
+class TestProductionAPISync:
+ """Integration tests for synchronous LLMClient against production API."""
+
+ @pytest.fixture(scope="class")
+ def client(self):
+ """Create LLMClient instance for testing."""
+ if not WALLET_KEY:
+ pytest.skip("BASE_CHAIN_WALLET_KEY not set")
+
+ client = LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API)
+
+ print("\n🧪 Running sync integration tests against production API")
+ print(f" Wallet: {client.get_wallet_address()}")
+ print(f" API: {PRODUCTION_API}")
+ print(" Estimated cost: ~$0.05\n")
+
+ return client
+
+ def test_list_models(self, client):
+ """Should list available models from production API."""
+ models = client.list_models()
+
+ assert models is not None
+ assert isinstance(models, list)
+ assert len(models) > 0
+
+ # Verify model structure
+ first_model = models[0]
+ assert "id" in first_model
+ assert "provider" in first_model
+ assert "inputPrice" in first_model
+ assert "outputPrice" in first_model
+
+ print(f" ✓ Found {len(models)} models")
+
+ # Respect rate limits
+ time.sleep(2)
+
+ def test_simple_chat_request(self, client):
+ """Should complete a simple chat request."""
+ # Use cheapest model for testing
+ response = client.chat(
+ "google/gemini-2.5-flash-lite",
+ [{"role": "user", "content": "Say 'test passed' and nothing else"}],
+ )
+
+ assert response is not None
+ assert isinstance(response, str)
+ assert "test passed" in response.lower()
+
+ print(f" ✓ Chat response: {response[:50]}...")
+
+ time.sleep(2)
+
+ def test_chat_completion_with_usage_stats(self, client):
+ """Should return chat completion with usage stats."""
+ completion = client.chat_completion(
+ "google/gemini-2.5-flash-lite",
+ [{"role": "user", "content": "Count to 5"}],
+ max_tokens=50,
+ )
+
+ assert completion is not None
+ assert "choices" in completion
+ assert len(completion["choices"]) > 0
+ assert "message" in completion["choices"][0]
+ assert "content" in completion["choices"][0]["message"]
+ assert completion["choices"][0]["message"]["content"]
+
+ # Verify usage stats
+ assert "usage" in completion
+ assert completion["usage"]["prompt_tokens"] > 0
+ assert completion["usage"]["completion_tokens"] > 0
+ assert completion["usage"]["total_tokens"] > 0
+
+ print(f" ✓ Completion with usage: {completion['usage']}")
+
+ time.sleep(2)
+
+ def test_payment_flow_end_to_end(self, client):
+ """Should handle 402 payment flow end-to-end.
+
+ This test verifies the full x402 payment protocol:
+ 1. Request to API
+ 2. Receive 402 with payment required
+ 3. Create payment payload with EIP-712 signature
+ 4. Retry with payment receipt
+ 5. Receive successful response
+ """
+ response = client.chat(
+ "google/gemini-2.5-flash-lite", [{"role": "user", "content": "What is 2+2?"}]
+ )
+
+ # If we got a response, the payment flow succeeded
+ assert response is not None
+ assert isinstance(response, str)
+ assert response
+
+ print(" ✓ Payment flow successful, response received")
+
+ time.sleep(2)
+
+
+class TestProductionAPIAsync:
+ """Integration tests for asynchronous AsyncLLMClient against production API."""
+
+ @pytest.fixture(scope="class")
+ async def async_client(self):
+ """Create AsyncLLMClient instance for testing."""
+ if not WALLET_KEY:
+ pytest.skip("BASE_CHAIN_WALLET_KEY not set")
+
+ client = AsyncLLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API)
+
+ print("\n🧪 Running async integration tests against production API")
+ print(f" Wallet: {client.get_wallet_address()}")
+ print(f" API: {PRODUCTION_API}")
+ print(" Estimated cost: ~$0.05\n")
+
+ return client
+
+ @pytest.mark.asyncio
+ async def test_async_list_models(self, async_client):
+ """Should list available models asynchronously."""
+ models = await async_client.list_models()
+
+ assert models is not None
+ assert isinstance(models, list)
+ assert len(models) > 0
+
+ print(f" ✓ Async: Found {len(models)} models")
+
+ await asyncio.sleep(2)
+
+ @pytest.mark.asyncio
+ async def test_async_simple_chat(self, async_client):
+ """Should complete a simple chat request asynchronously."""
+ response = await async_client.chat(
+ "google/gemini-2.5-flash-lite",
+ [{"role": "user", "content": "Say 'async test passed' and nothing else"}],
+ )
+
+ assert response is not None
+ assert isinstance(response, str)
+ assert "test passed" in response.lower()
+
+ print(f" ✓ Async chat response: {response[:50]}...")
+
+ await asyncio.sleep(2)
+
+ @pytest.mark.asyncio
+ async def test_async_chat_completion(self, async_client):
+ """Should return chat completion with usage stats asynchronously."""
+ completion = await async_client.chat_completion(
+ "google/gemini-2.5-flash-lite",
+ [{"role": "user", "content": "Count to 5"}],
+ max_tokens=50,
+ )
+
+ assert completion is not None
+ assert "choices" in completion
+ assert len(completion["choices"]) > 0
+ assert "usage" in completion
+ assert completion["usage"]["total_tokens"] > 0
+
+ print(f" ✓ Async completion with usage: {completion['usage']}")
+
+ await asyncio.sleep(2)
+
+
+class TestProductionAPIErrorHandling:
+ """Integration tests for error handling against production API."""
+
+ @pytest.fixture(scope="class")
+ def client(self):
+ """Create LLMClient instance for testing."""
+ if not WALLET_KEY:
+ pytest.skip("BASE_CHAIN_WALLET_KEY not set")
+
+ return LLMClient(private_key=WALLET_KEY, api_url=PRODUCTION_API)
+
+ def test_invalid_model_error(self, client):
+ """Should handle invalid model error gracefully."""
+ from blockrun_llm import APIError
+
+ with pytest.raises(APIError):
+ client.chat(
+ "invalid-model-that-does-not-exist",
+ [{"role": "user", "content": "test"}],
+ )
+
+ print(" ✓ Invalid model error handled correctly")
+
+ time.sleep(2)
+
+ def test_error_response_sanitization(self, client):
+ """Should sanitize error responses."""
+ from blockrun_llm import APIError
+
+ try:
+ client.chat("invalid-model", [{"role": "user", "content": "test"}])
+ pytest.fail("Should have raised APIError")
+ except APIError as e:
+ # Error should be sanitized (no internal stack traces, API keys, etc.)
+ assert e.message is not None
+ assert "/var/" not in str(e.message)
+ assert (
+ "internal" not in str(e.message).lower() or "internal" in str(e.message).lower()
+ ) # Allow "internal" in error message but not internal paths
+ assert "stack" not in str(e.message).lower()
+
+ print(" ✓ Error response properly sanitized")
+
+ time.sleep(2)
+
+
+# =============================================================================
+# Solana + Exa Integration Tests
+# =============================================================================
+
+SOLANA_WALLET_KEY = os.environ.get("SOLANA_WALLET_KEY")
+SOLANA_API = "https://sol.blockrun.ai/api"
+
+
+class TestSolanaExa:
+ """Integration tests for Exa web search via SolanaLLMClient."""
+
+ @pytest.fixture(scope="class")
+ def client(self):
+ if not SOLANA_WALLET_KEY:
+ pytest.skip("SOLANA_WALLET_KEY not set")
+ from blockrun_llm import SolanaLLMClient
+
+ c = SolanaLLMClient(private_key=SOLANA_WALLET_KEY, api_url=SOLANA_API)
+ print("\n🧪 Running Solana/Exa integration tests against sol.blockrun.ai")
+ print(f" Wallet: {c.get_wallet_address()}")
+ print(" Estimated cost: ~$0.04\n")
+ return c
+
+ def test_exa_search(self, client):
+ """exa_search returns results with title/url fields."""
+ result = client.exa_search("latest AI safety research", numResults=3)
+ assert "results" in result, f"Expected 'results' key, got: {list(result.keys())}"
+ assert len(result["results"]) > 0
+ first = result["results"][0]
+ assert "url" in first or "title" in first
+ cost = client.get_spending()["total_usd"]
+ assert 0.009 <= cost <= 0.011, f"Expected ~$0.01 cost, got {cost}"
+ print(f" ✓ exa_search: {len(result['results'])} results, cost=${cost:.4f}")
+ time.sleep(1)
+
+ def test_exa_find_similar(self, client):
+ """exa_find_similar returns semantically similar pages."""
+ result = client.exa_find_similar("https://openai.com/research/gpt-4", numResults=3)
+ assert "results" in result
+ assert len(result["results"]) > 0
+ print(f" ✓ exa_find_similar: {len(result['results'])} results")
+ time.sleep(1)
+
+ def test_exa_contents(self, client):
+ """exa_contents extracts text from a URL, priced per URL."""
+ result = client.exa_contents(["https://www.anthropic.com/research"])
+ assert result is not None
+ assert isinstance(result, dict)
+ print(" ✓ exa_contents: response received")
+ time.sleep(1)
+
+ def test_exa_answer(self, client):
+ """exa_answer returns an AI-generated answer from live web."""
+ result = client.exa_answer("What is Anthropic Claude?")
+ assert result is not None
+ assert isinstance(result, dict)
+ print(" ✓ exa_answer: response received")
+ time.sleep(1)
+
+ def test_exa_generic_proxy(self, client):
+ """exa() generic proxy works for any endpoint."""
+ result = client.exa("search", {"query": "blockrun.ai", "numResults": 2})
+ assert "results" in result
+ print(f" ✓ exa() generic: {len(result['results'])} results")
+ time.sleep(1)
+
+ def test_exa_spending_tracked(self, client):
+ """Session spending is tracked across Exa calls."""
+ spending = client.get_spending()
+ assert spending["total_usd"] > 0
+ assert spending["calls"] >= 3
+ print(f" ✓ Spending tracked: ${spending['total_usd']:.4f} over {spending['calls']} calls")
diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
index ceb7f13..0360524 100644
--- a/tests/unit/__init__.py
+++ b/tests/unit/__init__.py
@@ -1 +1 @@
-"""Unit tests for BlockRun LLM SDK."""
+"""Unit tests for BlockRun LLM SDK."""
diff --git a/tests/unit/test_settled_payment.py b/tests/unit/test_settled_payment.py
new file mode 100644
index 0000000..871d30e
--- /dev/null
+++ b/tests/unit/test_settled_payment.py
@@ -0,0 +1,373 @@
+"""Tests for the settled-payment boundary and the gateway clamp warning.
+
+Both mechanisms exist to protect money, and both were shipped without coverage.
+The rule they encode: signing is settlement, so exactly one PAYMENT-SIGNATURE
+leaves the process per user-initiated call, no matter how the paid leg fails.
+"""
+
+import httpx
+import pytest
+
+from blockrun_llm import LLMClient
+from blockrun_llm.client import (
+ _SETTLED_ATTR,
+ _mark_settled,
+ _should_fallback,
+ _warn_if_clamped,
+)
+from blockrun_llm.types import APIError, PaymentError
+
+from ..helpers import (
+ TEST_PRIVATE_KEY,
+ build_chat_response,
+ build_payment_required_response,
+)
+
+
+def _client(handler) -> LLMClient:
+ client = LLMClient(private_key=TEST_PRIVATE_KEY)
+ client._client = httpx.Client(transport=httpx.MockTransport(handler))
+ return client
+
+
+class TestSettledTagClassification:
+ def test_untagged_timeout_still_falls_back(self):
+ """An unpaid timeout is a genuine transient failure; keep retrying."""
+ assert _should_fallback(httpx.ReadTimeout("boom")) is True
+
+ def test_untagged_503_still_falls_back(self):
+ assert _should_fallback(APIError("upstream", 503, None)) is True
+
+ def test_settled_timeout_does_not_fall_back(self):
+ assert _should_fallback(_mark_settled(httpx.ReadTimeout("boom"))) is False
+
+ def test_settled_network_error_does_not_fall_back(self):
+ assert _should_fallback(_mark_settled(httpx.ConnectError("boom"))) is False
+
+ def test_settled_5xx_does_not_fall_back(self):
+ """The dominant post-settlement failure. Tagging only timeouts left
+ this open, so the six-settlement path survived the first fix."""
+ assert _should_fallback(_mark_settled(APIError("upstream", 503, None))) is False
+
+ def test_mark_settled_preserves_identity_and_type(self):
+ exc = httpx.ReadTimeout("boom")
+ assert _mark_settled(exc) is exc
+ with pytest.raises(httpx.TimeoutException):
+ raise exc
+
+
+class TestTagScope:
+ """What must NOT be tagged, driven through the real client. The handlers
+ were once `except Exception`, which labeled rejected payments and SDK bugs
+ as settled payments. These fail if the handlers widen again."""
+
+ def test_payment_rejection_is_not_tagged_as_settled(self):
+ """A paid-leg 402 means the facilitator refused; the funds did not
+ move. Calling that 'settled' is exactly backwards."""
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 402,
+ json={"error": "Payment Required"},
+ headers={"payment-required": build_payment_required_response()},
+ )
+
+ client = _client(handler)
+ with pytest.raises(PaymentError) as exc:
+ client.chat_completion("a/b", [{"role": "user", "content": "hi"}])
+ assert getattr(exc.value, _SETTLED_ATTR, False) is False
+
+ def test_programming_error_in_paid_leg_is_not_tagged(self, monkeypatch):
+ """An AttributeError raised after the paid response is an SDK bug. It
+ must propagate as itself, not as a settled-payment failure."""
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if "PAYMENT-SIGNATURE" in request.headers:
+ return httpx.Response(200, json=build_chat_response())
+ return httpx.Response(
+ 402,
+ json={"error": "Payment Required"},
+ headers={"payment-required": build_payment_required_response()},
+ )
+
+ client = _client(handler)
+
+ def boom(_response):
+ raise AttributeError("SDK bug in settlement capture")
+
+ monkeypatch.setattr(client, "_capture_settlement", boom)
+
+ with pytest.raises(AttributeError) as exc:
+ client.chat_completion("a/b", [{"role": "user", "content": "hi"}])
+ assert getattr(exc.value, _SETTLED_ATTR, False) is False
+
+ def test_tagged_types_are_a_superset_of_fallback_eligible(self):
+ """The handlers catch `(httpx.HTTPError, APIError)`. That must cover
+ everything _should_fallback says yes to, or a settled failure escapes
+ untagged and the chain pays again."""
+ assert issubclass(httpx.TimeoutException, httpx.HTTPError)
+ assert issubclass(httpx.NetworkError, httpx.HTTPError)
+ assert not issubclass(PaymentError, APIError)
+
+ def test_tagging_preserves_traceback_and_context(self):
+ """Handlers re-raise bare rather than `from None`, so an opaque wrapper
+ error keeps the cause that explains it."""
+ try:
+ try:
+ raise ValueError("underlying base64 failure")
+ except ValueError:
+ raise APIError("invalid format", 500, None)
+ except APIError as outer:
+ _mark_settled(outer)
+ assert isinstance(outer.__context__, ValueError)
+ assert outer.__suppress_context__ is False
+
+
+class TestNoSecondSettlement:
+ """One user-initiated call must never settle more than once."""
+
+ def _paid_leg_fails(self, failure):
+ # Count distinct signatures, not signed requests. The paid leg retries
+ # 502/503 once with the SAME PAYMENT-SIGNATURE, which is one settlement
+ # replayed, not a second charge. A new signature is a new settlement.
+ signed = set()
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if "PAYMENT-SIGNATURE" in request.headers:
+ signed.add(request.headers["PAYMENT-SIGNATURE"])
+ return failure()
+ return httpx.Response(
+ 402,
+ json={"error": "Payment Required"},
+ headers={"payment-required": build_payment_required_response()},
+ )
+
+ return signed, handler
+
+ def test_timeout_after_payment_does_not_pay_the_next_model(self):
+ def fail():
+ raise httpx.ReadTimeout("upstream hung after settlement")
+
+ signed, handler = self._paid_leg_fails(fail)
+ client = _client(handler)
+
+ with pytest.raises(httpx.TimeoutException):
+ client.chat_completion(
+ "primary/slow",
+ [{"role": "user", "content": "hi"}],
+ fallback_models=["fallback/good", "fallback/other"],
+ )
+ assert len(signed) == 1, f"settled {len(signed)} times for one call"
+
+ def test_paid_5xx_does_not_pay_the_next_model(self, monkeypatch):
+ """Regression: a 402-then-503 chain across 3 models signed six payments
+ and returned nothing."""
+ monkeypatch.setattr("time.sleep", lambda _s: None)
+ signed, handler = self._paid_leg_fails(
+ lambda: httpx.Response(503, json={"error": "upstream down"})
+ )
+ client = _client(handler)
+
+ with pytest.raises(APIError):
+ client.chat_completion(
+ "a/b",
+ [{"role": "user", "content": "hi"}],
+ fallback_models=["c/d", "e/f"],
+ )
+ assert len(signed) == 1, f"settled {len(signed)} times for one call"
+
+ def test_unpaid_failure_still_walks_the_chain(self):
+ """The guard must not disable legitimate free retries: if nothing was
+ signed, falling back costs the caller nothing."""
+ seen = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ import json as _json
+
+ model = _json.loads(request.read())["model"]
+ seen.append(model)
+ if model == "primary/bad":
+ return httpx.Response(503, json={"error": "down"})
+ return httpx.Response(200, json=build_chat_response())
+
+ client = _client(handler)
+ client.chat_completion(
+ "primary/bad",
+ [{"role": "user", "content": "hi"}],
+ fallback_models=["fallback/good"],
+ )
+ # primary appears twice: the unpaid leg retries 502/503 once before the
+ # chain advances. What matters is that it advanced at all.
+ assert "fallback/good" in seen
+
+
+class TestPaidStreamCleanup:
+ """Extracting the paid phase into its own generator changed who is
+ responsible for closing it."""
+
+ def test_abandoned_async_paid_stream_closes_its_inner_generator(self):
+ """Drives the real AsyncLLMClient. `async for` does not aclose the inner
+ generator when the outer is closed, so the paid
+ `async with self._client.stream(...)` would stay suspended and hold the
+ connection until GC finalization. Fails if the explicit
+ `finally: await paid.aclose()` is removed.
+ """
+ import asyncio
+
+ from blockrun_llm import AsyncLLMClient
+
+ closed = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 402,
+ json={"error": "Payment Required"},
+ headers={"payment-required": build_payment_required_response()},
+ )
+
+ async def run():
+ client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY)
+ client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+
+ async def fake_paid_phase(*_a, **_kw):
+ try:
+ yield {"chunk": 1}
+ yield {"chunk": 2}
+ finally:
+ closed.append(True)
+
+ client._astream_paid_phase = fake_paid_phase
+
+ stream = client.chat_completion_stream("a/b", [{"role": "user", "content": "hi"}])
+ assert await stream.__anext__() == {"chunk": 1}
+ # Abandon mid-stream, exactly like `break` in a caller's loop.
+ await stream.aclose()
+ # Assert HERE, not after asyncio.run(). Without the explicit
+ # aclose, CPython's asyncgen finalizer still closes the inner
+ # generator eventually during loop teardown — which is precisely
+ # the "connection held until GC" behavior being fixed. Only a
+ # synchronous check at the close point tells the two apart.
+ return list(closed)
+
+ assert asyncio.run(run()) == [True], "inner paid generator was not closed at aclose()"
+
+ def test_sync_delegation_closes_via_yield_from(self):
+ """The sync path gets this for free, which is why only the async path
+ needed the explicit close. Recorded so nobody 'fixes' it symmetrically."""
+ closed = []
+
+ def inner():
+ try:
+ yield 1
+ yield 2
+ finally:
+ closed.append(True)
+
+ def outer():
+ yield from inner()
+
+ gen = outer()
+ assert next(gen) == 1
+ gen.close()
+ assert closed == [True]
+
+
+class TestWarnIfClamped:
+ def test_warns_when_quoted_below_requested(self, capsys):
+ _warn_if_clamped(
+ {"model": "claude-opus-4.8", "max_tokens": 262144},
+ "claude-opus-4.8 chat completion, 128000 max output tokens",
+ )
+ err = capsys.readouterr().err
+ assert "clamped" in err and "262144" in err and "128000" in err
+
+ def test_parses_comma_grouped_ceiling(self, capsys):
+ _warn_if_clamped({"max_tokens": 200000}, "gpt-5.5, 128,000 max output tokens")
+ assert "128000" in capsys.readouterr().err
+
+ def test_silent_when_quoted_meets_the_request(self, capsys):
+ _warn_if_clamped({"max_tokens": 128000}, "128000 max output tokens")
+ _warn_if_clamped({"max_tokens": 1000}, "128000 max output tokens")
+ assert capsys.readouterr().err == ""
+
+ def test_silent_when_no_ceiling_in_description(self, capsys):
+ _warn_if_clamped({"max_tokens": 999999}, "BlockRun AI API call")
+ _warn_if_clamped({"max_tokens": 999999}, None)
+ _warn_if_clamped({"max_tokens": 999999}, "")
+ assert capsys.readouterr().err == ""
+
+ def test_bool_is_not_a_token_count(self, capsys):
+ _warn_if_clamped({"max_tokens": True}, "1 max output tokens")
+ assert capsys.readouterr().err == ""
+
+ def test_non_string_description_does_not_raise(self, capsys):
+ """The field is server-controlled and JSON allows anything. A warning
+ must never be the reason a paid request fails."""
+ _warn_if_clamped({"max_tokens": 100}, {"nested": "128 max output tokens"})
+ _warn_if_clamped({"max_tokens": 100}, 12345)
+ assert capsys.readouterr().err == ""
+
+ def test_ambiguous_description_stays_silent(self, capsys):
+ """A per-unit rate is not a ceiling. Two candidates means the format is
+ not what we think it is, so say nothing."""
+ _warn_if_clamped(
+ {"max_tokens": 4096, "model": "m"},
+ "$0.002 per 1000 max output tokens, 128000 max output tokens",
+ )
+ assert capsys.readouterr().err == ""
+
+ def test_long_description_does_not_hang(self, capsys):
+ """Outer defense: the scan limit caps what reaches the regex at all."""
+ import time
+
+ start = time.perf_counter()
+ _warn_if_clamped({"max_tokens": 100}, "9" * 100_000)
+ assert time.perf_counter() - start < 1.0
+ assert capsys.readouterr().err == ""
+
+ def test_pattern_itself_is_not_backtracking(self):
+ """Inner defense, pinned separately so removing the scan limit cannot
+ silently reintroduce the ReDoS. The old `(\\d[\\d,]*)` pattern took
+ ~1.95s on this input; the bounded one takes ~0.0002s.
+ """
+ import time
+
+ from blockrun_llm.client import _QUOTED_MAX_TOKENS_RE
+
+ start = time.perf_counter()
+ _QUOTED_MAX_TOKENS_RE.search("9" * 16_000)
+ assert time.perf_counter() - start < 0.05
+
+ def test_pattern_still_matches_the_real_shapes(self):
+ from blockrun_llm.client import _QUOTED_MAX_TOKENS_RE
+
+ for text, expected in (
+ ("claude-opus-4.8 - 128000 max output tokens", "128000"),
+ ("gpt-5.5, 128,000 max output tokens", "128,000"),
+ ("262144 MAX OUTPUT TOKENS", "262144"),
+ ):
+ assert _QUOTED_MAX_TOKENS_RE.findall(text) == [expected], text
+
+ def test_warning_fires_on_the_real_402_leg(self, capsys):
+ def handler(request: httpx.Request) -> httpx.Response:
+ if "PAYMENT-SIGNATURE" in request.headers:
+ return httpx.Response(200, json=build_chat_response())
+ return httpx.Response(
+ 402,
+ json={"error": "Payment Required"},
+ headers={
+ "payment-required": build_payment_required_response(
+ resource={
+ "url": "https://blockrun.ai/api/v1/chat/completions",
+ "description": "claude-opus-4.8 - 128000 max output tokens",
+ }
+ )
+ },
+ )
+
+ _client(handler).chat_completion(
+ "claude-opus-4.8",
+ [{"role": "user", "content": "hi"}],
+ max_tokens=262144,
+ )
+ assert "max_tokens clamped" in capsys.readouterr().err
diff --git a/tests/unit/test_solana_settled_payment.py b/tests/unit/test_solana_settled_payment.py
new file mode 100644
index 0000000..1d4d000
--- /dev/null
+++ b/tests/unit/test_solana_settled_payment.py
@@ -0,0 +1,65 @@
+"""Solana half of the settled-payment guard.
+
+Signing is settlement on either chain. The Base client learned not to let the
+fallback chain buy a retry after a payment went out; this pins the same rule for
+Solana, where the transfer is SPL USDC.
+
+Guarded with importorskip: the 3.9 CI job installs the SDK without the solana
+extra, and an unguarded Solana test file turns that job red (see #19/#20).
+"""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+pytest.importorskip("x402")
+pytest.importorskip("solders")
+
+from blockrun_llm.client import _mark_settled # noqa: E402
+from blockrun_llm.solana_client import _should_fallback_solana # noqa: E402
+from blockrun_llm.types import APIError, PaymentError # noqa: E402
+
+
+class TestSolanaSettledTag:
+ """Mirrors TestSettledTagClassification in test_settled_payment.py."""
+
+ def test_untagged_timeout_still_falls_back(self):
+ assert _should_fallback_solana(httpx.ReadTimeout("boom")) is True
+
+ def test_untagged_503_still_falls_back(self):
+ assert _should_fallback_solana(APIError("upstream", 503, None)) is True
+
+ def test_settled_timeout_does_not_fall_back(self):
+ assert _should_fallback_solana(_mark_settled(httpx.ReadTimeout("boom"))) is False
+
+ def test_settled_network_error_does_not_fall_back(self):
+ assert _should_fallback_solana(_mark_settled(httpx.ConnectError("boom"))) is False
+
+ def test_settled_5xx_does_not_fall_back(self):
+ """The dominant post-settlement failure, and the one the Base fix
+ originally missed."""
+ assert _should_fallback_solana(_mark_settled(APIError("upstream", 503, None))) is False
+
+ def test_payment_error_still_refused(self):
+ """Pre-existing behavior must survive the new first check."""
+ assert _should_fallback_solana(PaymentError("insufficient balance")) is False
+
+ def test_permanent_payment_reason_still_refused(self):
+ """The issue #6 guard: a transient type carrying a permanent reason."""
+ assert _should_fallback_solana(httpx.ReadTimeout("transaction_simulation_failed")) is False
+
+ def test_both_chains_agree_on_the_tag(self):
+ """The tag has to mean the same thing in both fallback chains, or one
+ of them keeps paying twice."""
+ from blockrun_llm.client import _should_fallback
+
+ for exc in (
+ httpx.ReadTimeout("boom"),
+ httpx.ConnectError("boom"),
+ APIError("upstream", 503, None),
+ ):
+ assert _should_fallback(exc) is True
+ assert _should_fallback_solana(exc) is True
+ assert _should_fallback(_mark_settled(exc)) is False
+ assert _should_fallback_solana(_mark_settled(exc)) is False
diff --git a/tests/unit/test_solana_wallet.py b/tests/unit/test_solana_wallet.py
index b05b461..ab47e2b 100644
--- a/tests/unit/test_solana_wallet.py
+++ b/tests/unit/test_solana_wallet.py
@@ -1,50 +1,50 @@
-"""Unit tests for Solana wallet utilities."""
-
-import pytest
-from blockrun_llm.solana_wallet import (
- create_solana_wallet,
- solana_key_to_bytes,
- get_solana_public_key,
-)
-
-# A valid test bs58 secret key (64 bytes, valid keypair from deterministic seed)
-TEST_BS58_KEY = (
- "433C7KFcM4y1ZEVdZYSH7wheSNAM384UcbgXEyD5FV7Q2HsQ1BwjEDx4GbBZUqPkZTVhFPyLyuZnzK8wCeAkU7wG"
-)
-
-
-class TestCreateSolanaWallet:
- def test_returns_address_and_key(self):
- wallet = create_solana_wallet()
- assert "address" in wallet
- assert "private_key" in wallet
- assert len(wallet["address"]) >= 32 # base58 pubkey
- assert len(wallet["private_key"]) >= 86 # bs58 64-byte key
-
- def test_unique_wallets(self):
- w1 = create_solana_wallet()
- w2 = create_solana_wallet()
- assert w1["address"] != w2["address"]
- assert w1["private_key"] != w2["private_key"]
-
-
-class TestSolanaKeyToBytes:
- def test_valid_key(self):
- b = solana_key_to_bytes(TEST_BS58_KEY)
- assert isinstance(b, bytes)
- assert len(b) == 64
-
- def test_invalid_key_raises(self):
- with pytest.raises(ValueError, match="Invalid Solana private key"):
- solana_key_to_bytes("not-a-valid-key!!!")
-
-
-class TestGetSolanaPublicKey:
- def test_returns_base58_address(self):
- addr = get_solana_public_key(TEST_BS58_KEY)
- assert isinstance(addr, str)
- assert len(addr) >= 32
- # Should be valid base58 (only alphanumeric, no 0/O/I/l)
- import re
-
- assert re.match(r"^[1-9A-HJ-NP-Za-km-z]+$", addr)
+"""Unit tests for Solana wallet utilities."""
+
+import pytest
+from blockrun_llm.solana_wallet import (
+ create_solana_wallet,
+ solana_key_to_bytes,
+ get_solana_public_key,
+)
+
+# A valid test bs58 secret key (64 bytes, valid keypair from deterministic seed)
+TEST_BS58_KEY = (
+ "433C7KFcM4y1ZEVdZYSH7wheSNAM384UcbgXEyD5FV7Q2HsQ1BwjEDx4GbBZUqPkZTVhFPyLyuZnzK8wCeAkU7wG"
+)
+
+
+class TestCreateSolanaWallet:
+ def test_returns_address_and_key(self):
+ wallet = create_solana_wallet()
+ assert "address" in wallet
+ assert "private_key" in wallet
+ assert len(wallet["address"]) >= 32 # base58 pubkey
+ assert len(wallet["private_key"]) >= 86 # bs58 64-byte key
+
+ def test_unique_wallets(self):
+ w1 = create_solana_wallet()
+ w2 = create_solana_wallet()
+ assert w1["address"] != w2["address"]
+ assert w1["private_key"] != w2["private_key"]
+
+
+class TestSolanaKeyToBytes:
+ def test_valid_key(self):
+ b = solana_key_to_bytes(TEST_BS58_KEY)
+ assert isinstance(b, bytes)
+ assert len(b) == 64
+
+ def test_invalid_key_raises(self):
+ with pytest.raises(ValueError, match="Invalid Solana private key"):
+ solana_key_to_bytes("not-a-valid-key!!!")
+
+
+class TestGetSolanaPublicKey:
+ def test_returns_base58_address(self):
+ addr = get_solana_public_key(TEST_BS58_KEY)
+ assert isinstance(addr, str)
+ assert len(addr) >= 32
+ # Should be valid base58 (only alphanumeric, no 0/O/I/l)
+ import re
+
+ assert re.match(r"^[1-9A-HJ-NP-Za-km-z]+$", addr)
diff --git a/tests/unit/test_x402.py b/tests/unit/test_x402.py
index 7665c3d..a93ac25 100644
--- a/tests/unit/test_x402.py
+++ b/tests/unit/test_x402.py
@@ -1,317 +1,317 @@
-"""Unit tests for x402 payment protocol."""
-
-import pytest
-import base64
-import json
-from blockrun_llm.x402 import (
- create_nonce,
- create_payment_payload,
- parse_payment_required,
- extract_payment_details,
-)
-from ..helpers import TEST_ACCOUNT, TEST_RECIPIENT
-
-
-class TestCreateNonce:
- def test_nonce_format(self):
- """Should generate nonce with correct format."""
- nonce = create_nonce()
-
- assert nonce.startswith("0x")
- assert len(nonce) == 66 # 0x + 64 hex chars
-
- def test_nonce_uniqueness(self):
- """Should generate unique nonces."""
- nonce1 = create_nonce()
- nonce2 = create_nonce()
-
- assert nonce1 != nonce2
-
- def test_nonce_is_hex(self):
- """Should contain only hex characters."""
- nonce = create_nonce()
- # Remove 0x prefix and check if valid hex
- hex_part = nonce[2:]
- assert all(c in "0123456789abcdef" for c in hex_part.lower())
-
-
-class TestCreatePaymentPayload:
- def test_create_valid_payload(self):
- """Should create valid payment payload."""
- payload = create_payment_payload(
- account=TEST_ACCOUNT,
- recipient=TEST_RECIPIENT,
- amount="1000000",
- network="eip155:8453",
- )
-
- assert isinstance(payload, str)
-
- # Decode and verify structure
- decoded = json.loads(base64.b64decode(payload))
- assert decoded["x402Version"] == 2
- assert "payload" in decoded
- assert "signature" in decoded["payload"]
- assert decoded["payload"]["signature"].startswith("0x")
-
- def test_payload_includes_authorization(self):
- """Should include authorization details."""
- payload = create_payment_payload(
- account=TEST_ACCOUNT,
- recipient=TEST_RECIPIENT,
- amount="1000000",
- )
-
- decoded = json.loads(base64.b64decode(payload))
- auth = decoded["payload"]["authorization"]
-
- assert auth["from"] == TEST_ACCOUNT.address
- assert auth["to"] == TEST_RECIPIENT
- assert auth["value"] == "1000000"
- assert "validAfter" in auth
- assert "validBefore" in auth
- assert "nonce" in auth
-
- def test_payload_attaches_builder_code_service_code(self):
- """Should tag every payment with the BlockRun service code (s)."""
- payload = create_payment_payload(
- account=TEST_ACCOUNT,
- recipient=TEST_RECIPIENT,
- amount="1000000",
- )
-
- decoded = json.loads(base64.b64decode(payload))
- assert decoded["extensions"]["builder-code"]["info"]["s"] == ["blockrun"]
-
- def test_payload_preserves_echoed_app_code(self):
- """Should keep the server-echoed app code (a) when adding service code (s)."""
- payload = create_payment_payload(
- account=TEST_ACCOUNT,
- recipient=TEST_RECIPIENT,
- amount="1000000",
- extensions={"builder-code": {"info": {"a": "blockrun"}}},
- )
-
- decoded = json.loads(base64.b64decode(payload))
- info = decoded["extensions"]["builder-code"]["info"]
- assert info["a"] == "blockrun"
- assert info["s"] == ["blockrun"]
-
- def test_payload_includes_resource_info(self):
- """Should include resource information."""
- payload = create_payment_payload(
- account=TEST_ACCOUNT,
- recipient=TEST_RECIPIENT,
- amount="1000000",
- resource_url="https://api.blockrun.ai/v1/test",
- resource_description="Test Resource",
- )
-
- decoded = json.loads(base64.b64decode(payload))
- assert decoded["resource"]["url"] == "https://api.blockrun.ai/v1/test"
- assert decoded["resource"]["description"] == "Test Resource"
-
- def test_payload_time_windows(self):
- """Should set valid time windows."""
- import time
-
- before = int(time.time())
- payload = create_payment_payload(
- account=TEST_ACCOUNT, recipient=TEST_RECIPIENT, amount="1000000"
- )
- after = int(time.time())
-
- decoded = json.loads(base64.b64decode(payload))
- auth = decoded["payload"]["authorization"]
-
- # Valid after should be in the past (allows clock skew)
- assert int(auth["validAfter"]) < before
-
- # Valid before should be in the future
- assert int(auth["validBefore"]) > after
-
- def test_custom_timeout(self):
- """Should use custom max timeout."""
- payload = create_payment_payload(
- account=TEST_ACCOUNT,
- recipient=TEST_RECIPIENT,
- amount="1000000",
- max_timeout_seconds=600,
- )
-
- decoded = json.loads(base64.b64decode(payload))
- assert decoded["accepted"]["maxTimeoutSeconds"] == 600
-
-
-class TestParsePaymentRequired:
- def test_parse_valid_header(self):
- """Should parse valid payment required header."""
- data = {
- "x402Version": 2,
- "accepts": [
- {
- "scheme": "exact",
- "network": "eip155:8453",
- "amount": "1000000",
- "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
- "payTo": TEST_RECIPIENT,
- "maxTimeoutSeconds": 300,
- }
- ],
- }
-
- encoded = base64.b64encode(json.dumps(data).encode()).decode()
- result = parse_payment_required(encoded)
-
- assert result["x402Version"] == 2
- assert len(result["accepts"]) == 1
-
- def test_invalid_base64(self):
- """Should raise ValueError on invalid base64."""
- with pytest.raises(ValueError, match="invalid format"):
- parse_payment_required("invalid!!!")
-
- def test_invalid_json(self):
- """Should raise ValueError on invalid JSON."""
- with pytest.raises(ValueError, match="invalid format"):
- parse_payment_required(base64.b64encode(b"not json").decode())
-
-
-class TestExtractPaymentDetails:
- def test_extract_details(self):
- """Should extract payment details."""
- payment_required = {
- "x402Version": 2,
- "accepts": [
- {
- "scheme": "exact",
- "network": "eip155:8453",
- "amount": "1000000",
- "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
- "payTo": TEST_RECIPIENT,
- "maxTimeoutSeconds": 300,
- }
- ],
- }
-
- details = extract_payment_details(payment_required)
-
- assert details["amount"] == "1000000"
- assert details["recipient"] == TEST_RECIPIENT
- assert details["network"] == "eip155:8453"
- assert details["maxTimeoutSeconds"] == 300
-
- def test_empty_accepts(self):
- """Should raise ValueError on empty accepts."""
- with pytest.raises(ValueError, match="No payment options"):
- extract_payment_details({"x402Version": 2, "accepts": []})
-
- def test_default_timeout(self):
- """Should use default timeout if not specified."""
- payment_required = {
- "x402Version": 2,
- "accepts": [
- {
- "scheme": "exact",
- "network": "eip155:8453",
- "amount": "1000000",
- "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
- "payTo": TEST_RECIPIENT,
- # maxTimeoutSeconds not specified
- }
- ],
- }
-
- details = extract_payment_details(payment_required)
- assert details["maxTimeoutSeconds"] == 300
-
- def test_include_resource(self):
- """Should include resource if present."""
- payment_required = {
- "x402Version": 2,
- "accepts": [
- {
- "scheme": "exact",
- "network": "eip155:8453",
- "amount": "1000000",
- "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
- "payTo": TEST_RECIPIENT,
- }
- ],
- "resource": {
- "url": "https://api.blockrun.ai/test",
- "description": "Test",
- },
- }
-
- details = extract_payment_details(payment_required)
- assert details["resource"]["url"] == "https://api.blockrun.ai/test"
-
-
-class TestSolanaX402SdkIntegration:
- """Tests for Solana x402 SDK integration."""
-
- USDC_SOLANA = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
- TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
- TEST_FEE_PAYER = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4"
- TEST_SOL_RECIPIENT = "AQqnMFBwGZEoti85aTVRy8XYpKrho7GaMDx9ZB3CEeKA"
-
- def test_decode_solana_payment_required(self):
- """Should decode a Solana 402 PaymentRequired header."""
- from x402.http.utils import decode_payment_required_header
-
- data = {
- "x402Version": 2,
- "accepts": [
- {
- "scheme": "exact",
- "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
- "amount": "1000",
- "asset": self.USDC_SOLANA,
- "payTo": self.TEST_SOL_RECIPIENT,
- "maxTimeoutSeconds": 300,
- "extra": {"feePayer": self.TEST_FEE_PAYER},
- }
- ],
- }
- encoded = base64.b64encode(json.dumps(data).encode()).decode()
- result = decode_payment_required_header(encoded)
-
- assert result.x402_version == 2
- assert len(result.accepts) == 1
- assert str(result.accepts[0].network).startswith("solana:")
- assert result.accepts[0].pay_to == self.TEST_SOL_RECIPIENT
- assert result.accepts[0].amount == "1000"
- assert result.accepts[0].extra["feePayer"] == self.TEST_FEE_PAYER
-
- def test_keypair_signer_address(self):
- """KeypairSigner should derive correct public key from bs58 secret."""
- from x402.mechanisms.svm import KeypairSigner
- from solders.keypair import Keypair
-
- # Generate a valid keypair and get its base58 representation
- kp = Keypair()
- expected_address = str(kp.pubkey())
-
- signer = KeypairSigner.from_base58(str(kp))
- assert signer.address == expected_address
-
- def test_ata_derivation_uses_correct_program_id(self):
- """ATA derivation must use the correct Associated Token Program ID."""
- from x402.mechanisms.svm import derive_ata
-
- # Known wallet -> known USDC ATA (verified on-chain)
- owner = "CtJTYWPQSL5jw9B2JRHmpQjYCSSgUX3LRvmMBhq55HmQ"
- expected_ata = "HZPPxg9ZyoHu4f2pj5uEEXsArLA2rnL9FtDgC8rrAp5Q"
-
- result = derive_ata(owner, self.USDC_SOLANA, self.TOKEN_PROGRAM_ID)
- assert result == expected_ata
-
- def test_is_solana_network(self):
- """Should correctly identify Solana networks."""
- from blockrun_llm.x402 import is_solana_network
-
- assert is_solana_network("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")
- assert is_solana_network("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1")
- assert not is_solana_network("eip155:8453")
- assert not is_solana_network("base-sepolia")
+"""Unit tests for x402 payment protocol."""
+
+import pytest
+import base64
+import json
+from blockrun_llm.x402 import (
+ create_nonce,
+ create_payment_payload,
+ parse_payment_required,
+ extract_payment_details,
+)
+from ..helpers import TEST_ACCOUNT, TEST_RECIPIENT
+
+
+class TestCreateNonce:
+ def test_nonce_format(self):
+ """Should generate nonce with correct format."""
+ nonce = create_nonce()
+
+ assert nonce.startswith("0x")
+ assert len(nonce) == 66 # 0x + 64 hex chars
+
+ def test_nonce_uniqueness(self):
+ """Should generate unique nonces."""
+ nonce1 = create_nonce()
+ nonce2 = create_nonce()
+
+ assert nonce1 != nonce2
+
+ def test_nonce_is_hex(self):
+ """Should contain only hex characters."""
+ nonce = create_nonce()
+ # Remove 0x prefix and check if valid hex
+ hex_part = nonce[2:]
+ assert all(c in "0123456789abcdef" for c in hex_part.lower())
+
+
+class TestCreatePaymentPayload:
+ def test_create_valid_payload(self):
+ """Should create valid payment payload."""
+ payload = create_payment_payload(
+ account=TEST_ACCOUNT,
+ recipient=TEST_RECIPIENT,
+ amount="1000000",
+ network="eip155:8453",
+ )
+
+ assert isinstance(payload, str)
+
+ # Decode and verify structure
+ decoded = json.loads(base64.b64decode(payload))
+ assert decoded["x402Version"] == 2
+ assert "payload" in decoded
+ assert "signature" in decoded["payload"]
+ assert decoded["payload"]["signature"].startswith("0x")
+
+ def test_payload_includes_authorization(self):
+ """Should include authorization details."""
+ payload = create_payment_payload(
+ account=TEST_ACCOUNT,
+ recipient=TEST_RECIPIENT,
+ amount="1000000",
+ )
+
+ decoded = json.loads(base64.b64decode(payload))
+ auth = decoded["payload"]["authorization"]
+
+ assert auth["from"] == TEST_ACCOUNT.address
+ assert auth["to"] == TEST_RECIPIENT
+ assert auth["value"] == "1000000"
+ assert "validAfter" in auth
+ assert "validBefore" in auth
+ assert "nonce" in auth
+
+ def test_payload_attaches_builder_code_service_code(self):
+ """Should tag every payment with the BlockRun service code (s)."""
+ payload = create_payment_payload(
+ account=TEST_ACCOUNT,
+ recipient=TEST_RECIPIENT,
+ amount="1000000",
+ )
+
+ decoded = json.loads(base64.b64decode(payload))
+ assert decoded["extensions"]["builder-code"]["info"]["s"] == ["blockrun"]
+
+ def test_payload_preserves_echoed_app_code(self):
+ """Should keep the server-echoed app code (a) when adding service code (s)."""
+ payload = create_payment_payload(
+ account=TEST_ACCOUNT,
+ recipient=TEST_RECIPIENT,
+ amount="1000000",
+ extensions={"builder-code": {"info": {"a": "blockrun"}}},
+ )
+
+ decoded = json.loads(base64.b64decode(payload))
+ info = decoded["extensions"]["builder-code"]["info"]
+ assert info["a"] == "blockrun"
+ assert info["s"] == ["blockrun"]
+
+ def test_payload_includes_resource_info(self):
+ """Should include resource information."""
+ payload = create_payment_payload(
+ account=TEST_ACCOUNT,
+ recipient=TEST_RECIPIENT,
+ amount="1000000",
+ resource_url="https://api.blockrun.ai/v1/test",
+ resource_description="Test Resource",
+ )
+
+ decoded = json.loads(base64.b64decode(payload))
+ assert decoded["resource"]["url"] == "https://api.blockrun.ai/v1/test"
+ assert decoded["resource"]["description"] == "Test Resource"
+
+ def test_payload_time_windows(self):
+ """Should set valid time windows."""
+ import time
+
+ before = int(time.time())
+ payload = create_payment_payload(
+ account=TEST_ACCOUNT, recipient=TEST_RECIPIENT, amount="1000000"
+ )
+ after = int(time.time())
+
+ decoded = json.loads(base64.b64decode(payload))
+ auth = decoded["payload"]["authorization"]
+
+ # Valid after should be in the past (allows clock skew)
+ assert int(auth["validAfter"]) < before
+
+ # Valid before should be in the future
+ assert int(auth["validBefore"]) > after
+
+ def test_custom_timeout(self):
+ """Should use custom max timeout."""
+ payload = create_payment_payload(
+ account=TEST_ACCOUNT,
+ recipient=TEST_RECIPIENT,
+ amount="1000000",
+ max_timeout_seconds=600,
+ )
+
+ decoded = json.loads(base64.b64decode(payload))
+ assert decoded["accepted"]["maxTimeoutSeconds"] == 600
+
+
+class TestParsePaymentRequired:
+ def test_parse_valid_header(self):
+ """Should parse valid payment required header."""
+ data = {
+ "x402Version": 2,
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "eip155:8453",
+ "amount": "1000000",
+ "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "payTo": TEST_RECIPIENT,
+ "maxTimeoutSeconds": 300,
+ }
+ ],
+ }
+
+ encoded = base64.b64encode(json.dumps(data).encode()).decode()
+ result = parse_payment_required(encoded)
+
+ assert result["x402Version"] == 2
+ assert len(result["accepts"]) == 1
+
+ def test_invalid_base64(self):
+ """Should raise ValueError on invalid base64."""
+ with pytest.raises(ValueError, match="invalid format"):
+ parse_payment_required("invalid!!!")
+
+ def test_invalid_json(self):
+ """Should raise ValueError on invalid JSON."""
+ with pytest.raises(ValueError, match="invalid format"):
+ parse_payment_required(base64.b64encode(b"not json").decode())
+
+
+class TestExtractPaymentDetails:
+ def test_extract_details(self):
+ """Should extract payment details."""
+ payment_required = {
+ "x402Version": 2,
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "eip155:8453",
+ "amount": "1000000",
+ "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "payTo": TEST_RECIPIENT,
+ "maxTimeoutSeconds": 300,
+ }
+ ],
+ }
+
+ details = extract_payment_details(payment_required)
+
+ assert details["amount"] == "1000000"
+ assert details["recipient"] == TEST_RECIPIENT
+ assert details["network"] == "eip155:8453"
+ assert details["maxTimeoutSeconds"] == 300
+
+ def test_empty_accepts(self):
+ """Should raise ValueError on empty accepts."""
+ with pytest.raises(ValueError, match="No payment options"):
+ extract_payment_details({"x402Version": 2, "accepts": []})
+
+ def test_default_timeout(self):
+ """Should use default timeout if not specified."""
+ payment_required = {
+ "x402Version": 2,
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "eip155:8453",
+ "amount": "1000000",
+ "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "payTo": TEST_RECIPIENT,
+ # maxTimeoutSeconds not specified
+ }
+ ],
+ }
+
+ details = extract_payment_details(payment_required)
+ assert details["maxTimeoutSeconds"] == 300
+
+ def test_include_resource(self):
+ """Should include resource if present."""
+ payment_required = {
+ "x402Version": 2,
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "eip155:8453",
+ "amount": "1000000",
+ "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "payTo": TEST_RECIPIENT,
+ }
+ ],
+ "resource": {
+ "url": "https://api.blockrun.ai/test",
+ "description": "Test",
+ },
+ }
+
+ details = extract_payment_details(payment_required)
+ assert details["resource"]["url"] == "https://api.blockrun.ai/test"
+
+
+class TestSolanaX402SdkIntegration:
+ """Tests for Solana x402 SDK integration."""
+
+ USDC_SOLANA = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+ TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
+ TEST_FEE_PAYER = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4"
+ TEST_SOL_RECIPIENT = "AQqnMFBwGZEoti85aTVRy8XYpKrho7GaMDx9ZB3CEeKA"
+
+ def test_decode_solana_payment_required(self):
+ """Should decode a Solana 402 PaymentRequired header."""
+ from x402.http.utils import decode_payment_required_header
+
+ data = {
+ "x402Version": 2,
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ "amount": "1000",
+ "asset": self.USDC_SOLANA,
+ "payTo": self.TEST_SOL_RECIPIENT,
+ "maxTimeoutSeconds": 300,
+ "extra": {"feePayer": self.TEST_FEE_PAYER},
+ }
+ ],
+ }
+ encoded = base64.b64encode(json.dumps(data).encode()).decode()
+ result = decode_payment_required_header(encoded)
+
+ assert result.x402_version == 2
+ assert len(result.accepts) == 1
+ assert str(result.accepts[0].network).startswith("solana:")
+ assert result.accepts[0].pay_to == self.TEST_SOL_RECIPIENT
+ assert result.accepts[0].amount == "1000"
+ assert result.accepts[0].extra["feePayer"] == self.TEST_FEE_PAYER
+
+ def test_keypair_signer_address(self):
+ """KeypairSigner should derive correct public key from bs58 secret."""
+ from x402.mechanisms.svm import KeypairSigner
+ from solders.keypair import Keypair
+
+ # Generate a valid keypair and get its base58 representation
+ kp = Keypair()
+ expected_address = str(kp.pubkey())
+
+ signer = KeypairSigner.from_base58(str(kp))
+ assert signer.address == expected_address
+
+ def test_ata_derivation_uses_correct_program_id(self):
+ """ATA derivation must use the correct Associated Token Program ID."""
+ from x402.mechanisms.svm import derive_ata
+
+ # Known wallet -> known USDC ATA (verified on-chain)
+ owner = "CtJTYWPQSL5jw9B2JRHmpQjYCSSgUX3LRvmMBhq55HmQ"
+ expected_ata = "HZPPxg9ZyoHu4f2pj5uEEXsArLA2rnL9FtDgC8rrAp5Q"
+
+ result = derive_ata(owner, self.USDC_SOLANA, self.TOKEN_PROGRAM_ID)
+ assert result == expected_ata
+
+ def test_is_solana_network(self):
+ """Should correctly identify Solana networks."""
+ from blockrun_llm.x402 import is_solana_network
+
+ assert is_solana_network("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")
+ assert is_solana_network("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1")
+ assert not is_solana_network("eip155:8453")
+ assert not is_solana_network("base-sepolia")