Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

All notable changes to blockrun-llm will be documented in this file.

## 1.9.0 — 2026-07-21

### Added
- **Client-side spend limits.** `max_cost_per_call` and `max_session_cost` on
every client (Base and Solana, sync and async) refuse a quote that costs more
than you allowed:

```python
client = LLMClient(max_cost_per_call=0.25, max_session_cost=10.00)
```

Also settable per-deployment without code changes, via
`BLOCKRUN_MAX_COST_PER_CALL` and `BLOCKRUN_MAX_SESSION_COST`. An explicit
argument wins over the env var; a malformed env value is ignored rather than
raising, so a bad deploy variable cannot brick every client.

The refusal happens **before the paid request is sent**, so nothing settles
and nothing is charged — signing alone moves no money, the gateway submitting
the signed authorization does. The new `SpendLimitError` carries `quoted_usd`,
`limit_usd` and `scope` (`"call"` or `"session"`), and subclasses
`PaymentError` so existing handlers keep working and the model fallback chain
refuses it rather than shopping for a cheaper model.

Both limits are **opt-in and unset by default**, so nothing changes for
existing callers. Until now the SDK had no ceiling anywhere: it computed
`cost_usd` and signed the quote in the next statement, with nothing compared
against anything — while `chat_completion` documented a
`PaymentError: If budget is set and would be exceeded` for a `budget`
parameter that did not exist. That docstring is now true.

## 1.8.2 — 2026-07-21

Supersedes 1.8.1, which was published from a tree where `VERSION` and
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.8.2
1.9.0
4 changes: 3 additions & 1 deletion blockrun_llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
Model,
APIError,
PaymentError,
SpendLimitError,
ImageResponse,
ImageData,
ImageModel,
Expand Down Expand Up @@ -178,7 +179,7 @@
)
from .tx_log import TransactionLogger, decode_settlement_header, format_row

__version__ = "1.8.2"
__version__ = "1.9.0"
__all__ = [
"LLMClient",
"AsyncLLMClient",
Expand Down Expand Up @@ -218,6 +219,7 @@
"Model",
"APIError",
"PaymentError",
"SpendLimitError",
"ImageResponse",
"ImageData",
"ImageModel",
Expand Down
81 changes: 73 additions & 8 deletions blockrun_llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,17 @@
)
from .x402 import create_payment_payload, parse_payment_required, extract_payment_details
from .validation import (
validate_private_key,
validate_eth_address,
check_spend_limits,
resolve_spend_limit,
sanitize_error_response,
validate_api_url,
validate_model,
validate_eth_address,
validate_max_tokens,
validate_model,
validate_private_key,
validate_resource_url,
validate_temperature,
validate_top_p,
sanitize_error_response,
validate_resource_url,
)

# Load environment variables
Expand Down Expand Up @@ -277,6 +279,26 @@ def _warn_if_clamped(body: Dict[str, Any], resource_description: Optional[str])
return


def _enforce_spend_limits(client: Any, cost_usd: float, model: Optional[str] = None) -> None:
"""Refuse a quote that breaches a limit the caller configured, before the
paid request is sent.

A free function rather than a method because the four client classes (sync
and async, Base and Solana) do not share a base class, and a spend limit
that applies to three of them is not a spend limit.

No-op unless the caller opted in. See
:func:`blockrun_llm.validation.check_spend_limits`.
"""
check_spend_limits(
cost_usd,
max_cost_per_call=client._max_cost_per_call,
max_session_cost=client._max_session_cost,
session_spent_usd=client._session_total_usd,
model=model,
)


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``
Expand Down Expand Up @@ -335,6 +357,8 @@ def __init__(
timeout: float = DEFAULT_CHAT_TIMEOUT,
search_timeout: float = 300.0,
transaction_log: Union[bool, str, "os.PathLike[str]", None] = None,
max_cost_per_call: Optional[float] = None,
max_session_cost: Optional[float] = None,
):
"""
Initialize the BlockRun LLM client.
Expand Down Expand Up @@ -407,6 +431,13 @@ def __init__(

# Session spending tracking
self._session_total_usd: float = 0.0
# Opt-in spend limits. None (the default) means unlimited, which is the
# behavior every release before 1.9.0 had: every 402 quote was signed
# automatically with nothing compared against anything.
self._max_cost_per_call = resolve_spend_limit(
max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL"
)
self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST")
self._session_calls: int = 0
self._last_call_cost: float = 0.0

Expand Down Expand Up @@ -667,9 +698,13 @@ def chat_completion(

Raises:
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.
an insufficient USDC balance).
SpendLimitError: If the quote exceeds ``max_cost_per_call`` or would
push the client past ``max_session_cost``. Both are opt-in and
unset by default; when unset, every 402 quote is signed
automatically. Raised before the request is sent, so a refused
quote costs nothing. ``SpendLimitError`` subclasses
``PaymentError``.

Example:
messages = [
Expand Down Expand Up @@ -1140,6 +1175,8 @@ def _sign_payment_from_response(
if price_info
else float(details.get("amount", 0)) / 1e6
)
# Before signing: a refused quote is never sent, so nothing settles.
_enforce_spend_limits(self, cost_usd, body.get("model") if isinstance(body, dict) else None)

resource = details.get("resource") or {}
_warn_if_clamped(body, resource.get("description"))
Expand Down Expand Up @@ -1277,6 +1314,8 @@ def _handle_payment_and_retry(
if price_info
else float(details.get("amount", 0)) / 1e6
)
# Before signing: a refused quote is never sent, so nothing settles.
_enforce_spend_limits(self, cost_usd, body.get("model") if isinstance(body, dict) else None)

# Create signed payment payload (v2 format)
# SECURITY: Signing happens locally - only the signature is sent to server
Expand Down Expand Up @@ -1460,6 +1499,8 @@ def _handle_payment_and_retry_raw(
if price_info
else float(details.get("amount", 0)) / 1e6
)
# Before signing: a refused quote is never sent, so nothing settles.
_enforce_spend_limits(self, cost_usd, body.get("model") if isinstance(body, dict) else None)

resource = details.get("resource") or {}
_warn_if_clamped(body, resource.get("description"))
Expand Down Expand Up @@ -1601,6 +1642,8 @@ def _handle_get_payment_and_retry(
if price_info
else float(details.get("amount", 0)) / 1e6
)
# Before signing: a refused quote is never sent, so nothing settles.
_enforce_spend_limits(self, cost_usd)

resource = details.get("resource") or {}
extensions = payment_required.get("extensions", {})
Expand Down Expand Up @@ -2340,6 +2383,8 @@ def __init__(
timeout: float = DEFAULT_CHAT_TIMEOUT,
search_timeout: float = 300.0,
transaction_log: Union[bool, str, "os.PathLike[str]", None] = None,
max_cost_per_call: Optional[float] = None,
max_session_cost: Optional[float] = None,
):
"""
Initialize the async BlockRun LLM client.
Expand Down Expand Up @@ -2400,6 +2445,17 @@ def __init__(
limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
)
self._last_call_cost: float = 0.0
# This client tracks no session total (see chat_completion), so the
# session limit has nothing to accumulate against; the per-call limit
# still applies. Kept as an attribute so the shared check is uniform.
self._session_total_usd: float = 0.0
# Opt-in spend limits. None (the default) means unlimited, which is the
# behavior every release before 1.9.0 had: every 402 quote was signed
# automatically with nothing compared against anything.
self._max_cost_per_call = resolve_spend_limit(
max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL"
)
self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST")

log_dir = _resolve_log_dir(transaction_log)
self._tx_logger: Optional[TransactionLogger] = (
Expand Down Expand Up @@ -2888,6 +2944,15 @@ async def _handle_payment_and_retry(

details = extract_payment_details(payment_required)

# Enforce the spend limit on the QUOTE, before signing. This handler
# computes its cost_usd only after the paid POST returns (it prefers the
# price echoed on the response), which is far too late to refuse.
_enforce_spend_limits(
self,
float(details.get("amount", 0)) / 1e6,
body.get("model") if isinstance(body, dict) else None,
)

# Create signed payment payload (v2 format)
# SECURITY: Signing happens locally - only the signature is sent to server
resource = details.get("resource") or {}
Expand Down
39 changes: 38 additions & 1 deletion blockrun_llm/solana_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
from .realface import _GROUP_ID_RE
from .validation import (
build_payment_rejected_error,
resolve_spend_limit,
sanitize_error_response,
validate_api_url,
validate_image_quality,
Expand All @@ -75,7 +76,7 @@
# "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
from .client import _SETTLED_ATTR, _enforce_spend_limits, _mark_settled

try:
from x402 import x402ClientSync
Expand Down Expand Up @@ -466,6 +467,8 @@ def __init__(
search_timeout: float = DEFAULT_SEARCH_TIMEOUT,
rpc_headers: Optional[Dict[str, str]] = None,
transaction_log: Union[bool, str, "os.PathLike[str]", None] = None,
max_cost_per_call: Optional[float] = None,
max_session_cost: Optional[float] = None,
) -> None:
"""Initialise the Solana client.

Expand Down Expand Up @@ -527,6 +530,13 @@ def __init__(
# search / per-call overrides are applied per request below.
self._client = httpx.Client(timeout=timeout)
self._session_total_usd = 0.0
# Opt-in spend limits. None (the default) means unlimited, which is the
# behavior every release before 1.9.0 had: every 402 quote was signed
# automatically with nothing compared against anything.
self._max_cost_per_call = resolve_spend_limit(
max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL"
)
self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST")
self._session_calls = 0
self._last_call_cost: float = 0.0
self._address: Optional[str] = None
Expand Down Expand Up @@ -1080,6 +1090,10 @@ def _sign_payment_from_response(

payment_required = decode_payment_required_header(payment_header)
payment_payload = self._sign_payment(payment_required)
# Before the paid request goes out. Signing alone moves nothing; the
# gateway submitting the signed authorization does, so refusing here
# means nothing settles.
_enforce_spend_limits(self, float(payment_payload.accepted.amount) / 1e6)
encoded_payment = encode_payment_signature_header(payment_payload)

cost_usd = float(payment_payload.accepted.amount) / 1e6
Expand Down Expand Up @@ -1186,6 +1200,10 @@ def _handle_payment_and_retry(
# Use x402 SDK to decode 402 response and create signed payment
payment_required = decode_payment_required_header(payment_header)
payment_payload = self._sign_payment(payment_required)
# Before the paid request goes out. Signing alone moves nothing; the
# gateway submitting the signed authorization does, so refusing here
# means nothing settles.
_enforce_spend_limits(self, float(payment_payload.accepted.amount) / 1e6, body.get("model"))
encoded_payment = encode_payment_signature_header(payment_payload)

payment_headers = {
Expand Down Expand Up @@ -1318,6 +1336,10 @@ def _handle_payment_and_retry_raw(
# Use x402 SDK to decode 402 response and create signed payment
payment_required = decode_payment_required_header(payment_header)
payment_payload = self._sign_payment(payment_required)
# Before the paid request goes out. Signing alone moves nothing; the
# gateway submitting the signed authorization does, so refusing here
# means nothing settles.
_enforce_spend_limits(self, float(payment_payload.accepted.amount) / 1e6, body.get("model"))
encoded_payment = encode_payment_signature_header(payment_payload)

payment_headers = {
Expand Down Expand Up @@ -1427,6 +1449,10 @@ def _handle_get_payment_and_retry(

payment_required = decode_payment_required_header(payment_header)
payment_payload = self._sign_payment(payment_required)
# Before the paid request goes out. Signing alone moves nothing; the
# gateway submitting the signed authorization does, so refusing here
# means nothing settles.
_enforce_spend_limits(self, float(payment_payload.accepted.amount) / 1e6)
encoded_payment = encode_payment_signature_header(payment_payload)

payment_headers = {
Expand Down Expand Up @@ -2802,6 +2828,8 @@ def __init__(
search_timeout: float = DEFAULT_SEARCH_TIMEOUT,
rpc_headers: Optional[Dict[str, str]] = None,
transaction_log: Union[bool, str, "os.PathLike[str]", None] = None,
max_cost_per_call: Optional[float] = None,
max_session_cost: Optional[float] = None,
) -> None:
"""Async mirror of :class:`SolanaLLMClient.__init__`. Same env-var
fallback for ``rpc_url`` / ``rpc_headers`` — see
Expand Down Expand Up @@ -2838,6 +2866,13 @@ def __init__(
self._search_timeout = search_timeout
self._client = httpx.AsyncClient(timeout=timeout)
self._session_total_usd = 0.0
# Opt-in spend limits. None (the default) means unlimited, which is the
# behavior every release before 1.9.0 had: every 402 quote was signed
# automatically with nothing compared against anything.
self._max_cost_per_call = resolve_spend_limit(
max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL"
)
self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST")
self._session_calls = 0
self._last_call_cost: float = 0.0
self._address: Optional[str] = None
Expand Down Expand Up @@ -3308,6 +3343,8 @@ async def _sign_payment_from_response(
raise PaymentError("402 response but no payment requirements found")
payment_required = decode_payment_required_header(payment_header)
payment_payload = await self._sign_payment(payment_required)
# See the sync path: refusing here means nothing is ever sent.
_enforce_spend_limits(self, float(payment_payload.accepted.amount) / 1e6)
encoded_payment = encode_payment_signature_header(payment_payload)
cost_usd = float(payment_payload.accepted.amount) / 1e6
return (
Expand Down
27 changes: 27 additions & 0 deletions blockrun_llm/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,33 @@ def __init__(
self.response = response


class SpendLimitError(PaymentError):
"""A quote exceeded a spend limit the caller configured, so it was refused.

Raised *before* the paid request goes out, so nothing settles: the quote is
declined locally and no funds move. Subclasses :class:`PaymentError` so
existing ``except PaymentError`` handlers keep working, and so the model
fallback chain refuses it — retrying another model after declining on cost
would defeat the limit.

``quoted_usd`` is what the gateway asked for, ``limit_usd`` is the ceiling
that refused it, and ``scope`` is ``"call"`` or ``"session"``.
"""

def __init__(
self,
message: str,
*,
quoted_usd: float,
limit_usd: float,
scope: str,
) -> None:
super().__init__(message)
self.quoted_usd = quoted_usd
self.limit_usd = limit_usd
self.scope = scope


class APIError(BlockrunError):
"""API-related error."""

Expand Down
Loading
Loading