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")