From ef4e37cc71bddd72f7e310137afca76c1c084e60 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Tue, 21 Jul 2026 11:58:54 -0500 Subject: [PATCH] feat(payments): client-side spend limits (1.9.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK had no spend ceiling anywhere. client.py 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 never existed. A typo in max_tokens, a model swap, or a gateway price change could all become a real charge with no local check between the quote and the signature. max_cost_per_call and max_session_cost on all four clients (Base and Solana, sync and async), also settable per-deployment via BLOCKRUN_MAX_COST_PER_CALL and BLOCKRUN_MAX_SESSION_COST. Both unset by default, so nothing changes for existing callers. Enforcement happens before the paid request is sent, which is what makes it free: signing alone moves no money, the gateway submitting the signed authorization does. Placement mattered more than expected — three handlers compute cost_usd only AFTER the paid POST returns (they prefer the price echoed on the response), so the guard there reads the quote off the 402 instead. The async Base handler had this wrong at first and the mutation test caught it. SpendLimitError subclasses PaymentError, so existing handlers keep working and _should_fallback refuses it — shopping the next model for a cheaper quote would defeat the limit and sign a second one. It carries quoted_usd, limit_usd and scope for callers that want to react rather than just fail. A malformed env var is ignored rather than raising: a bad deploy variable must not brick every client. An explicit non-positive argument still raises, since that is a programming error. 18 new tests. Every one drives a real client through a MockTransport and asserts no PAYMENT-SIGNATURE was ever sent, rather than testing the helper in isolation. Mutation-verified: no-oping either check, dropping the sync or async enforcement, disabling env resolution, or making SpendLimitError fallback-eligible each fails tests. --- CHANGELOG.md | 30 +++++ VERSION | 2 +- blockrun_llm/__init__.py | 4 +- blockrun_llm/client.py | 81 +++++++++++-- blockrun_llm/solana_client.py | 39 +++++- blockrun_llm/types.py | 27 +++++ blockrun_llm/validation.py | 74 ++++++++++++ pyproject.toml | 2 +- tests/unit/test_spend_limits.py | 203 ++++++++++++++++++++++++++++++++ 9 files changed, 450 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_spend_limits.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2ce0f..f288fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 53adb84..f8e233b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.2 +1.9.0 diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index 1dc80f0..14a4f3f 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -87,6 +87,7 @@ Model, APIError, PaymentError, + SpendLimitError, ImageResponse, ImageData, ImageModel, @@ -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", @@ -218,6 +219,7 @@ "Model", "APIError", "PaymentError", + "SpendLimitError", "ImageResponse", "ImageData", "ImageModel", diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 64cabe9..c3ef64f 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -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 @@ -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`` @@ -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. @@ -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 @@ -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 = [ @@ -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")) @@ -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 @@ -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")) @@ -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", {}) @@ -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. @@ -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] = ( @@ -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 {} diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 6598281..5dbaabd 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -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, @@ -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 @@ -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. @@ -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 @@ -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 @@ -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 = { @@ -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 = { @@ -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 = { @@ -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 @@ -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 @@ -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 ( diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index 7ca0322..c9fdf8c 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -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.""" diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py index b82738c..859c5a6 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -535,3 +535,77 @@ def validate_resource_url(url: str, base_url: str) -> str: except Exception: # Invalid URL format, return safe default return f"{base_url}/v1/chat/completions" + + +def resolve_spend_limit(explicit: Optional[float], env_var: str) -> Optional[float]: + """Resolve a spend limit from the constructor argument or its env var. + + ``None`` means unlimited, which is the default and the pre-1.9.0 behavior. + An unparseable or non-positive env value is ignored rather than raising: + a malformed env var must not brick every client in a deployment, and the + explicit argument always wins. + """ + if explicit is not None: + limit = float(explicit) + if limit <= 0: + raise ValueError(f"spend limit must be positive; got {explicit!r}") + return limit + + import os + + raw = os.environ.get(env_var) + if not raw: + return None + try: + limit = float(raw) + except ValueError: + return None + return limit if limit > 0 else None + + +def check_spend_limits( + cost_usd: float, + *, + max_cost_per_call: Optional[float], + max_session_cost: Optional[float], + session_spent_usd: float, + model: Optional[str] = None, +) -> None: + """Refuse a quote that would breach a caller-configured spend limit. + + Call this after the gateway's price is known and BEFORE the paid request is + sent. Signing alone moves no money — the gateway submitting the signed + authorization does — so declining here means nothing settles. + + Both limits are opt-in. With neither set this is a no-op, which is why + adding it changes no existing behavior. + + Raises: + SpendLimitError: If the quote exceeds the per-call limit, or if it would + push the session past its total. + """ + from .types import SpendLimitError + + where = f" for {model}" if model else "" + + if max_cost_per_call is not None and cost_usd > max_cost_per_call: + raise SpendLimitError( + f"Refused a ${cost_usd:.6f} quote{where}: it exceeds the per-call " + f"limit of ${max_cost_per_call:.6f}. Nothing was sent and nothing " + f"was charged. Raise max_cost_per_call to allow it.", + quoted_usd=cost_usd, + limit_usd=max_cost_per_call, + scope="call", + ) + + if max_session_cost is not None and session_spent_usd + cost_usd > max_session_cost: + remaining = max_session_cost - session_spent_usd + raise SpendLimitError( + f"Refused a ${cost_usd:.6f} quote{where}: this client has spent " + f"${session_spent_usd:.6f} of its ${max_session_cost:.6f} session " + f"limit, leaving ${remaining:.6f}. Nothing was sent and nothing was " + f"charged.", + quoted_usd=cost_usd, + limit_usd=max_session_cost, + scope="session", + ) diff --git a/pyproject.toml b/pyproject.toml index 83b9bd8..4a23eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.8.2" +version = "1.9.0" 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/tests/unit/test_spend_limits.py b/tests/unit/test_spend_limits.py new file mode 100644 index 0000000..a1d948c --- /dev/null +++ b/tests/unit/test_spend_limits.py @@ -0,0 +1,203 @@ +"""Client-side spend limits. + +Before 1.9.0 there was no ceiling anywhere: `client.py` computed `cost_usd` and +signed the quote in the next statement, with nothing compared against anything. +`chat_completion` even documented a `PaymentError: If budget is set and would be +exceeded` for a `budget` parameter that did not exist. + +The rule these tests encode: when a limit refuses a quote, **no paid request is +ever sent**. Signing alone moves no money — the gateway submitting the signed +authorization does — so a refusal before the send costs the caller nothing. +""" + +import httpx +import pytest + +from blockrun_llm import LLMClient +from blockrun_llm.types import PaymentError, SpendLimitError +from blockrun_llm.validation import check_spend_limits, resolve_spend_limit + +from ..helpers import ( + TEST_PRIVATE_KEY, + build_chat_response, + build_payment_required_response, +) + +MESSAGES = [{"role": "user", "content": "hi"}] + +# build_payment_required_response defaults to amount "1000000" = 1 USDC. +QUOTED_USD = 1.0 + + +def _client(**kwargs): + """A client whose paid leg fails the test if it is ever reached.""" + signed = [] + + def handler(request: httpx.Request) -> httpx.Response: + if "PAYMENT-SIGNATURE" in request.headers: + signed.append(request) + return httpx.Response(200, json=build_chat_response()) + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": build_payment_required_response()}, + ) + + client = LLMClient(private_key=TEST_PRIVATE_KEY, **kwargs) + client._client = httpx.Client(transport=httpx.MockTransport(handler)) + return client, signed + + +class TestNoLimitsIsUnchanged: + def test_default_client_still_pays(self): + """Limits are opt-in. Omitting them must behave exactly as before.""" + client, signed = _client() + client.chat_completion("a/b", MESSAGES) + assert len(signed) == 1 + + def test_helper_is_a_noop_without_limits(self): + check_spend_limits( + 999.0, max_cost_per_call=None, max_session_cost=None, session_spent_usd=0.0 + ) + + +class TestPerCallLimit: + def test_refuses_over_limit_quote_without_sending(self): + client, signed = _client(max_cost_per_call=0.10) + with pytest.raises(SpendLimitError) as exc: + client.chat_completion("a/b", MESSAGES) + assert signed == [], "a refused quote must never be sent" + assert exc.value.scope == "call" + assert exc.value.quoted_usd == pytest.approx(QUOTED_USD) + assert exc.value.limit_usd == pytest.approx(0.10) + + def test_allows_quote_at_or_under_limit(self): + client, signed = _client(max_cost_per_call=QUOTED_USD) + client.chat_completion("a/b", MESSAGES) + assert len(signed) == 1, "the limit is inclusive" + + def test_message_names_both_numbers_and_the_model(self): + client, _ = _client(max_cost_per_call=0.10) + with pytest.raises(SpendLimitError) as exc: + client.chat_completion("anthropic/claude-opus-4.8", MESSAGES) + msg = str(exc.value) + assert "1.000000" in msg and "0.100000" in msg + assert "claude-opus-4.8" in msg + assert "nothing was charged" in msg.lower() + + def test_nothing_is_recorded_as_spent(self): + client, _ = _client(max_cost_per_call=0.10) + with pytest.raises(SpendLimitError): + client.chat_completion("a/b", MESSAGES) + assert client.get_spending()["total_usd"] == 0.0 + assert client.get_spending()["calls"] == 0 + + +class TestSessionLimit: + def test_refuses_the_call_that_would_breach_the_total(self): + client, signed = _client(max_session_cost=1.5) + client.chat_completion("a/b", MESSAGES) # 1.0 spent, 0.5 left + assert len(signed) == 1 + with pytest.raises(SpendLimitError) as exc: + client.chat_completion("a/b", MESSAGES) # would reach 2.0 + assert len(signed) == 1, "the second quote must not be sent" + assert exc.value.scope == "session" + + def test_message_reports_what_is_left(self): + client, _ = _client(max_session_cost=1.5) + client.chat_completion("a/b", MESSAGES) + with pytest.raises(SpendLimitError) as exc: + client.chat_completion("a/b", MESSAGES) + assert "0.500000" in str(exc.value) + + +class TestAsyncClient: + """The async handler computes its cost_usd only after the paid POST returns, + so the guard has to read the quote off the 402 instead. Without a test the + limit silently ran too late to refuse anything.""" + + def _run(self, **kwargs): + from blockrun_llm import AsyncLLMClient + + signed = [] + + def handler(request: httpx.Request) -> httpx.Response: + if "PAYMENT-SIGNATURE" in request.headers: + signed.append(request) + return httpx.Response(200, json=build_chat_response()) + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": build_payment_required_response()}, + ) + + async def go(): + client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY, **kwargs) + client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return await client.chat_completion("a/b", MESSAGES) + + return go, signed + + def test_refuses_over_limit_without_sending(self): + import asyncio + + go, signed = self._run(max_cost_per_call=0.10) + with pytest.raises(SpendLimitError): + asyncio.run(go()) + assert signed == [], "the async path must refuse before the paid POST" + + def test_allows_quote_under_limit(self): + import asyncio + + go, signed = self._run(max_cost_per_call=QUOTED_USD) + asyncio.run(go()) + assert len(signed) == 1 + + +class TestErrorContract: + def test_is_a_payment_error(self): + """Existing `except PaymentError` handlers must keep working.""" + assert issubclass(SpendLimitError, PaymentError) + + def test_does_not_trigger_model_fallback(self): + """Falling back to another model after refusing on cost would defeat + the limit, and would sign a second quote.""" + from blockrun_llm.client import _should_fallback + + exc = SpendLimitError("x", quoted_usd=1.0, limit_usd=0.1, scope="call") + assert _should_fallback(exc) is False + + def test_fallback_chain_refuses_rather_than_shopping_for_a_cheaper_model(self): + client, signed = _client(max_cost_per_call=0.10) + with pytest.raises(SpendLimitError): + client.chat_completion("a/b", MESSAGES, fallback_models=["c/d", "e/f"]) + assert signed == [], "must not try the next model looking for a cheaper quote" + + +class TestLimitResolution: + def test_explicit_argument_wins_over_env(self, monkeypatch): + monkeypatch.setenv("BLOCKRUN_MAX_COST_PER_CALL", "5.0") + assert resolve_spend_limit(0.25, "BLOCKRUN_MAX_COST_PER_CALL") == 0.25 + + def test_env_var_applies_when_no_argument(self, monkeypatch): + monkeypatch.setenv("BLOCKRUN_MAX_COST_PER_CALL", "0.25") + assert resolve_spend_limit(None, "BLOCKRUN_MAX_COST_PER_CALL") == 0.25 + + def test_env_var_reaches_a_real_client(self, monkeypatch): + monkeypatch.setenv("BLOCKRUN_MAX_COST_PER_CALL", "0.10") + client, signed = _client() + with pytest.raises(SpendLimitError): + client.chat_completion("a/b", MESSAGES) + assert signed == [] + + def test_malformed_env_is_ignored_not_fatal(self, monkeypatch): + """A bad env var must not brick every client in a deployment.""" + for bad in ("abc", "", "-1", "0"): + monkeypatch.setenv("BLOCKRUN_MAX_COST_PER_CALL", bad) + assert resolve_spend_limit(None, "BLOCKRUN_MAX_COST_PER_CALL") is None + + def test_explicit_non_positive_is_a_programming_error(self): + with pytest.raises(ValueError, match="positive"): + resolve_spend_limit(0, "BLOCKRUN_MAX_COST_PER_CALL") + with pytest.raises(ValueError, match="positive"): + resolve_spend_limit(-1.0, "BLOCKRUN_MAX_COST_PER_CALL")