diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eacdf2..60bc786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,80 @@ All notable changes to blockrun-llm will be documented in this file. +## Unreleased + +### Added +- **x402 `upto` (Permit2) on Base: pay the actual cost, not the quote.** Under + `exact` the wallet signs a fixed pre-call quote, so a discount the gateway + only learns after the call — a DeepSeek prompt-cache hit is $0.028/M input + instead of $0.14/M — could never reach an x402 caller. When a 402 also offers + `upto` (listed after `exact`), `LLMClient` / `AsyncLLMClient` chat calls + (non-stream and stream) now sign a Permit2 `PermitWitnessTransferFrom` for the + ceiling and the gateway settles the actual amount. + + Upto settles the actual cost, which is not always below the exact quote: + exact prices output at a tenth of `max_tokens`, so a long answer settles for + more under upto (never more than the ceiling, the full `max_tokens`), and a + short or cache-hit one for less. `payment_scheme="exact"` keeps the fixed + quote. Upto is taken when the offer carries + `extra.facilitatorAddress` for USDC on a network in `EVM_NETWORKS`; the wallet + holds the ceiling; and Permit2 may already pull it, or the 402 declares + `eip2612GasSponsoring` — then a gasless EIP-2612 permit approving Permit2 for + exactly the ceiling rides along and the facilitator submits it, so a wallet + with no ETH works. (Exactly the ceiling: the upto proxy reverts with + `Permit2612AmountMismatch` on any other value, and CDP rejects MaxUint256.) + Balance / allowance / nonce come from public Base RPCs with a 3 s timeout + each. Anything else — no RPC, a signing error, a ceiling over a spend limit — + signs `exact` as before (debug log only). A payment the gateway rejects + before serving anything is retried once with `exact`, and that client stays + on exact for that network for 10 minutes. A rejection that follows a 502/503 + replay of the same upto payment is never retried with exact (the first send + may have settled; paying exact too would charge twice). A 402 offering only + upto, when upto cannot be used, raises rather than signing the ceiling as an + exact transfer. Exact spend limits cap on the signed amount (the header's, + which includes the transaction fee), not the body's base price. Streams book + the ceiling whatever their pre-settlement `PAYMENT-RESPONSE` says. + + Per wallet and network only one gas-sponsored upto payment is in flight: a + second permit over the same USDC nonce reverted on-chain in a live test of + the TS SDK. The SDK records the nonce each permit signs over and, while the + on-chain nonce has not moved and the permit's deadline has not passed, pays + exact for any call that would need another. Concurrent calls in one client + (threads or asyncio) claim a per-wallet preflight marker before their chain + read, so only one of them can sign a permit; the others pay upto only if + Permit2's allowance already covers their ceiling, else exact. Solana, the Anthropic client and + every non-chat endpoint are unchanged. + + Signing matches the official `@x402/evm` 2.28.0 client byte for byte: the + tests pin a vector it generated (`scripts/gen-upto-vector.mjs`). + + Opt out with `payment_scheme="exact"` or `BLOCKRUN_PAYMENT_SCHEME=exact`. +- **A ceiling is not a charge.** `ChatResponse.payment_scheme` / + `cost_is_ceiling` (also on stream chunks), `get_spending()["ceiling_usd"]`, + a `cost_basis` field on cost-log rows (`upto_settled` / `upto_ceiling`) and a + `(upto ceiling)` marker in `transactions.log`. An upto call books the settled + amount when `PAYMENT-RESPONSE` reports one, else the ceiling, labeled. Spend + limits count the ceiling in full. + +### Changed +- Exact chat payments book and cap on the signed requirement's amount when the + 402 also offers upto (the body's `price` is then the upto ceiling), and the + async client books a paid chat call the same way the sync one does. +- `extract_payment_details` picks the first non-`upto` requirement instead of + blindly taking `accepts[0]`. +- The upto→exact retry happens only on a definite verification failure + (`Payment verification failed`, `PAYMENT_INVALID` / `PAYMENT_UNFUNDED`, or a + fresh `payment-required` challenge), never on `PAYMENT_REPLAY` or a body + pointing at an earlier paid use (`recoverable` / `poll_url` / `job_id`): that + authorization was already served and paid for, and exact would charge twice. + A `PAYMENT_REPLAY` raises a `PaymentError` carrying the gateway's message and + `poll_url`, not "check your wallet balance". `extract_payment_details` now + raises `ValueError` for an upto-only 402 (chat opts in with + `allow_upto=True`), and `create_payment_payload` refuses a non-`exact` + `scheme`, so no non-chat endpoint signs an upto ceiling as an EIP-3009 + transfer. +- `get_balance()` reads its USDC contract and RPC list from `EVM_NETWORKS`. + ## 1.17.1 — 2026-09-30 ### Fixed diff --git a/README.md b/README.md index ed3753e..5ff59cf 100644 --- a/README.md +++ b/README.md @@ -416,12 +416,70 @@ That single call does all of this under the hood: One call, no separate pay step. +#### Pay the actual cost: x402 `upto` (Base) + +By default (`payment_scheme="auto"`) a chat call whose 402 also offers the x402 +`upto` scheme pays with a Permit2 signature for a **ceiling** instead of an +`exact` EIP-3009 transfer for a fixed quote. The gateway then settles the +**actual** cost after the call — never more than the ceiling — so discounts it +only learns afterwards, such as prompt-cache hits, reach you. + +Upto is not always cheaper. The `exact` quote prices output at a tenth of your +`max_tokens`; under upto you pay for the output you actually got. A short or +cache-hit answer costs less than exact, a long one costs more (up to the +ceiling, which prices the full `max_tokens`). If you need the fixed quote, pass +`payment_scheme="exact"`. The gateway lists `exact` first; upto is taken only +when all of these hold: + +- the 402 offers an EVM `upto` requirement with `extra.facilitatorAddress`, for + USDC on a network the SDK signs on (Base, Base Sepolia); +- your wallet holds at least the ceiling in USDC; +- Permit2 can already pull the ceiling from your wallet, **or** the gateway + declares the `eip2612GasSponsoring` extension. In that case the SDK also signs + a gasless EIP-2612 permit approving the canonical Permit2 contract + (`0x0000…78BA3`) for exactly the ceiling, and the facilitator submits it — + **no ETH needed**; +- the ceiling fits your `max_cost_per_call` / `max_session_cost`. + +Otherwise — or on any RPC or signing error — the SDK signs `exact` exactly as +before. If the gateway rejects an upto payment before serving anything, the +request is retried once with `exact`, and that client pays exact on that network +for the next 10 minutes. It is never retried with exact after a 502/503 replay +of the same upto payment, because the first send may already have settled. +A 402 that offers only upto, when upto cannot be used, raises instead of being +signed as exact. Solana is unchanged (exact only). + +**Per wallet, only one gas-sponsored upto payment can be in flight.** Its permit +lands only when that call settles, and until then the chain still shows the old +USDC permit nonce — a second permit over the same nonce would revert on-chain. +So concurrent or not-yet-settled calls that would need a permit pay `exact` +(the SDK watches the on-chain nonce, and gives up on a permit at its deadline; +concurrent calls in one client claim the permit slot before reading the chain). +Calls where Permit2 can already pull the ceiling are unaffected. These guards +are per process: several processes paying from one wallet can still collide +(a call fails; nobody is overcharged), so run one paying process per wallet or +use `exact` there. + +```python +LLMClient(payment_scheme="exact") # opt out; or BLOCKRUN_PAYMENT_SCHEME=exact +``` + +The signed ceiling is an upper bound, not a charge. `ChatResponse.payment_scheme` +says which scheme paid; when the gateway's `PAYMENT-RESPONSE` reports the settled +amount, `cost_usd` is that amount; when it does not (always, for streams, whose +header arrives before settlement), `cost_usd` is the ceiling and +`cost_is_ceiling` is `True`. `get_spending()["ceiling_usd"]` is the part of +`total_usd` booked at a ceiling, cost-log rows carry `cost_basis` +(`upto_settled` / `upto_ceiling`), and `transactions.log` marks ceiling rows. +Spend limits count ceilings in full, so they err on the safe side. + ### What it costs, and how to verify it - **Pay-as-you-go, per call.** You pay only the gateway price of each request (see [Available Models](#available-models)). The free NVIDIA models are `$0`. - **Track spend.** `client.get_spending()` returns this session's - `{total_usd, calls}`. On the API-key rail the gateway does not tell the client + `{total_usd, calls, ceiling_usd}` (`ceiling_usd`: see + [upto](#pay-the-actual-cost-x402-upto-base)). On the API-key rail the gateway does not tell the client what a call cost, so treat that total as a floor and [user.blockrun.ai/dashboard](https://user.blockrun.ai/dashboard) as the authority. Every paid call also appends a line to @@ -1647,6 +1705,7 @@ optional. | `SOLANA_RPC_URL` / `SOLANA_RPC_HEADERS` / `SOLANA_RPC_API_KEY` | RPC for blockhash + mint info while signing | BlockRun's free proxy | | `BLOCKRUN_CHAT_TIMEOUT` | Chat HTTP timeout, in seconds | `600` | | `BLOCKRUN_MAX_COST_PER_CALL` / `BLOCKRUN_MAX_SESSION_COST` | Opt-in spend limits (wallet rail) | unlimited | +| `BLOCKRUN_PAYMENT_SCHEME` | `auto` (prefer x402 `upto`, which settles the actual cost, when the gateway offers it and the wallet can use it) or `exact` (always the fixed quote) | `auto` | `BLOCKRUN_API_KEY_URL` is deliberately not `BLOCKRUN_API_URL`: that one names an x402 gateway, and an API-key client must never follow it and send your key to a diff --git a/blockrun_llm/anthropic_client.py b/blockrun_llm/anthropic_client.py index 8242356..f6a81ae 100644 --- a/blockrun_llm/anthropic_client.py +++ b/blockrun_llm/anthropic_client.py @@ -86,6 +86,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: account=self._account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self._api_url}/v1/messages"), resource_description=resource.get("description", "BlockRun AI API call"), diff --git a/blockrun_llm/cache.py b/blockrun_llm/cache.py index 2f9f079..7a77c2d 100644 --- a/blockrun_llm/cache.py +++ b/blockrun_llm/cache.py @@ -149,10 +149,16 @@ def save_to_cache( wallet: str | None = None, network: str | None = None, client_kind: str | None = None, + cost_basis: str | None = None, ) -> None: """ Save a paid API response locally. + ``cost_basis`` labels a cost that is not a plain exact charge: an x402 upto + call records ``"upto_settled"`` (the gateway reported the settled amount) + or ``"upto_ceiling"`` (``cost_usd`` is the signed ceiling — an upper bound, + not a confirmed charge). ``None`` (exact) writes the same rows as before. + 1. Hash-keyed cache file (for TTL-based dedup) 2. Human-readable data file (browsable archive of every paid call) 3. Cost log entry (with billing metadata when supplied) @@ -167,6 +173,8 @@ def save_to_cache( "response": response, "cost_usd": cost_usd, } + if cost_basis is not None: + entry["cost_basis"] = cost_basis try: _cache_path(key).write_text(json.dumps(entry, default=str)) @@ -174,7 +182,7 @@ def save_to_cache( pass # Save human-readable copy to ~/.blockrun/data/ - _save_readable(endpoint, body, response, cost_usd) + _save_readable(endpoint, body, response, cost_usd, cost_basis=cost_basis) # Append to the cost log (never overwritten). Pull model from the body # if the caller didn't pass one explicitly. @@ -185,6 +193,7 @@ def save_to_cache( wallet=wallet, network=network, client_kind=client_kind, + cost_basis=cost_basis, ) @@ -193,6 +202,8 @@ def _save_readable( body: dict[str, Any], response: dict[str, Any], cost_usd: float, + *, + cost_basis: str | None = None, ) -> None: """Save a human-readable JSON file to ~/.blockrun/data/.""" DATA_DIR.mkdir(parents=True, exist_ok=True) @@ -204,6 +215,8 @@ def _save_readable( "request": body, "response": response, } + if cost_basis is not None: + entry["cost_basis"] = cost_basis try: (DATA_DIR / filename).write_text(json.dumps(entry, indent=2, default=str)) except OSError: @@ -218,12 +231,15 @@ def _append_cost_log( wallet: str | None = None, network: str | None = None, client_kind: str | None = None, + cost_basis: str | None = None, ) -> None: """Append one JSONL row to ``~/.blockrun/cost_log.jsonl``. The full schema is:: - {ts, endpoint, cost_usd, model, wallet, network, client_kind} + {ts, endpoint, cost_usd, model, wallet, network, client_kind, cost_basis} + + ``cost_basis`` is present only for x402 upto calls (see ``save_to_cache``). Older rows that only carry ``{ts, endpoint, cost_usd}`` are still readable — missing fields surface as ``None`` in summary / export views. @@ -247,6 +263,8 @@ def _append_cost_log( entry["network"] = network if client_kind is not None: entry["client_kind"] = client_kind + if cost_basis is not None: + entry["cost_basis"] = cost_basis f.write(json.dumps(entry) + "\n") except OSError: pass diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index 56cf4ea..1275536 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -43,8 +43,9 @@ import os import re import sys -from collections.abc import AsyncIterator, Iterator -from typing import Any +import time +from collections.abc import AsyncGenerator, AsyncIterator, Iterator +from typing import Any, Callable, NamedTuple, NoReturn import httpx from dotenv import load_dotenv @@ -85,6 +86,7 @@ SearchResult, SmartChatCompletionResponse, SmartChatResponse, + SpendLimitError, chunk_meta, chunk_usage_dict, retry_after_of, @@ -104,7 +106,22 @@ validate_temperature, validate_top_p, ) -from .x402 import create_payment_payload, extract_payment_details, parse_payment_required +from .x402 import ( + EVM_NETWORKS, + create_payment_payload, + extract_payment_details, + parse_payment_required, +) +from .x402_upto import ( + UptoOption, + UptoPayment, + atry_upto_payment, + find_upto_option, + offers_upto, + resolve_payment_scheme, + settled_upto_amount, + try_upto_payment, +) # Load environment variables load_dotenv() @@ -336,6 +353,384 @@ def _enforce_spend_limits(client: Any, cost_usd: float, model: str | None = None ) +# --------------------------------------------------------------------------- +# Chat payment signing: exact (EIP-3009) or upto (Permit2) +# --------------------------------------------------------------------------- + + +# How long a gateway's rejection of an upto payment keeps this client on exact +# for that wallet and network. Long enough not to hammer a gateway that has +# switched upto off, short enough that one transient "verification failed" +# does not cost the discount for the rest of a long-running process. +UPTO_REJECTION_TTL_SECONDS = 600.0 + + +class _ChatPayment(NamedTuple): + """A signed chat payment: the paid retry's headers, and what to book for it.""" + + headers: dict[str, str] + # exact: the quote, which is what settles. upto: the signed CEILING, which + # is the most that can settle — never book it as paid when a settled + # amount is known (see _booked_cost). + cost_usd: float + scheme: str # "exact" | "upto" + amount_micro: int # the signed amount, micro-USDC + # upto only: the network signed on, and how to sign the same 402's exact + # requirement instead if the gateway rejects the upto payment. + network: str | None = None + exact_fallback: Callable[[], _ChatPayment] | None = None + + +def _rejection_body(response: httpx.Response) -> dict[str, Any] | None: + """The JSON object body of a refusal, or ``None`` when it has none.""" + try: + body = response.json() + except Exception: + return None + return body if isinstance(body, dict) else None + + +def _rejection_error_text(body: dict[str, Any]) -> str: + error = body.get("error") + if isinstance(error, dict): + error = error.get("message") + return error if isinstance(error, str) else "" + + +def _is_payment_rejection(response: httpx.Response) -> bool: + """Did the gateway refuse the payment itself (before serving anything)? + + A 402 on the paid request, or a 4xx whose body is the gateway's + payment-verification failure (``error: "Payment verification failed"`` / + a ``PAYMENT_*`` code). + + This decides how a refusal is RAISED. Whether it may be answered with a + second, exact payment is narrower: see :func:`_is_verify_failure`. + """ + status = response.status_code + if status == 402: + return True + if not 400 <= status < 500: + return False + body = _rejection_body(response) + if body is None: + return False + code = body.get("code") + return "payment verification failed" in _rejection_error_text(body).lower() or ( + isinstance(code, str) and code.upper().startswith("PAYMENT_") + ) + + +# The codes the gateway's chat route puts on a VERIFY-phase refusal +# (blockrun src/app/api/v1/chat/completions/route.ts → verifyFailureFields in +# src/lib/x402.ts). Verify runs before the nonce is claimed and before anything +# is served or settled, so these prove the signed payment was never used. +_VERIFY_FAILURE_CODES = frozenset({"PAYMENT_INVALID", "PAYMENT_UNFUNDED"}) + + +def _is_payment_replay(response: httpx.Response) -> bool: + """Did the gateway refuse the payment because its authorization was + ALREADY USED (``code: "PAYMENT_REPLAY"``, or a body pointing at the earlier + request's result: ``recoverable`` / ``poll_url`` / ``job_id``)? + + That earlier use was served and paid for: a proxy or load balancer may + have duplicated the first send. Nothing about this request may be paid + again. + """ + if response.status_code < 400: + return False + body = _rejection_body(response) + if body is None: + return False + code = body.get("code") + if isinstance(code, str) and code.upper() == "PAYMENT_REPLAY": + return True + return any(body.get(k) for k in ("recoverable", "poll_url", "job_id")) + + +def _is_verify_failure(response: httpx.Response) -> bool: + """Is this a DEFINITE pre-use refusal of the payment — one that proves the + signed authorization was never consumed? + + An allowlist, not "any 402": the gateway's verify-failure body + (``error: "Payment verification failed"`` or a verify-failure ``code``), or + a 402 that answers the paid request with a fresh ``payment-required`` + challenge (the payment was not accepted at all). Never a replay. + """ + if not 400 <= response.status_code < 500 or _is_payment_replay(response): + return False + body = _rejection_body(response) + if body is not None: + code = body.get("code") + if "payment verification failed" in _rejection_error_text(body).lower(): + return True + if isinstance(code, str) and code.upper() in _VERIFY_FAILURE_CODES: + return True + return response.status_code == 402 and bool(response.headers.get("payment-required")) + + +def _may_fall_back_to_exact( + payment: _ChatPayment, response: httpx.Response, *, replayed: bool +) -> bool: + """May this rejection of an upto payment be answered with an exact one? + + Only when the refused send was the FIRST send of that upto signature, and + the refusal is a definite verification failure (:func:`_is_verify_failure`). + + * Upto settles after the call is served, so if a 5xx made us replay the + same header, the replay's rejection can mean the first send was served + and settled (Permit2 nonce used). + * ``PAYMENT_REPLAY`` on the first send means the authorization was already + used by an earlier request that completed and was paid for (e.g. a + duplicated send). + + Paying exact in either case would charge twice. + """ + return payment.exact_fallback is not None and not replayed and _is_verify_failure(response) + + +def _payment_rejected_error(response: httpx.Response) -> PaymentError: + """The error for a paid request whose payment the gateway refused (a 402). + + A ``PAYMENT_REPLAY`` is not a balance problem and must not read as "just + retry": a retry signs a fresh authorization, and is charged again for a + request that was already paid for. Keep the gateway's message and, when it + names one, the earlier result's ``poll_url``. + """ + if not _is_payment_replay(response): + return PaymentError("Payment was rejected. Check your wallet balance.") + body = _rejection_body(response) or {} + gateway_message = body.get("message") + poll_url = body.get("poll_url") + detail: dict[str, Any] = sanitize_error_response(body) + for key in ("poll_url", "job_id", "recoverable"): + if body.get(key) is not None: + detail[key] = body[key] + text = ( + "The gateway refused this payment as already used (PAYMENT_REPLAY): an earlier " + "request with the same authorization was accepted and may have been charged. " + "Nothing more was signed. Retrying signs a new authorization and is billed " + "again, so check what the earlier request returned first." + ) + if isinstance(poll_url, str) and poll_url: + text += f" Its result can be collected at {poll_url}." + if isinstance(gateway_message, str) and gateway_message: + text += f" Gateway: {gateway_message}" + return PaymentError(text, status_code=response.status_code, response=detail) + + +def _exact_after_upto_rejection(client: Any, payment: _ChatPayment) -> _ChatPayment: + """The gateway rejected an upto payment: remember that for this wallet and + network (for the life of the client), and sign the 402's exact requirement. + + Called at most once per request, and only before any response body was + delivered — a 2xx or a stream in progress is never retried. + """ + assert payment.exact_fallback is not None + if client.account is not None and payment.network: + client._upto_rejected[(client.account.address.lower(), payment.network)] = time.monotonic() + sys.stderr.write( + f"[blockrun_llm] upto payment rejected on {payment.network}; retrying once " + f"with exact, and using exact on that network for {UPTO_REJECTION_TTL_SECONDS:.0f}s\n" + ) + return payment.exact_fallback() + + +def _payment_headers(payment_payload: str) -> dict[str, str]: + return { + "Content-Type": "application/json", + "User-Agent": _get_user_agent(), + "PAYMENT-SIGNATURE": payment_payload, + } + + +def _read_payment_required(response: httpx.Response) -> tuple[dict[str, Any], dict[str, Any]]: + """``(payment_required, price_info)`` from a 402: the ``payment-required`` + header, else an ``x402`` body (whose ``price`` then rides along).""" + payment_header: Any = response.headers.get("payment-required") + price_info: dict[str, Any] = {} + if not payment_header: + try: + resp_body = response.json() + if "x402" in resp_body: + payment_header = resp_body + price_info = resp_body.get("price", {}) + except Exception: + pass + + if not payment_header: + raise PaymentError("402 response but no payment requirements found") + + if isinstance(payment_header, str): + return parse_payment_required(payment_header), price_info + return payment_header, price_info + + +def _within_spend_limits(client: Any, body: dict[str, Any]) -> Callable[[float], bool]: + """A predicate: would signing this much pass the client's spend limits?""" + model = body.get("model") if isinstance(body, dict) else None + + def ok(cost_usd: float) -> bool: + try: + _enforce_spend_limits(client, cost_usd, model) + except SpendLimitError: + return False + return True + + return ok + + +def _upto_offer( + client: Any, + body: dict[str, Any], + payment_required: dict[str, Any], + details: dict[str, Any], +) -> tuple[UptoOption, dict[str, Any]] | None: + """The upto offer worth trying, with ``try_upto_payment``'s keyword + arguments — or ``None``, meaning sign exact exactly as before.""" + if getattr(client, "_payment_scheme", "auto") != "auto" or client.account is None: + return None + option = find_upto_option(payment_required) + if option is None: + return None + rejected_at = getattr(client, "_upto_rejected", {}).get( + (client.account.address.lower(), option.network) + ) + if rejected_at is not None and time.monotonic() - rejected_at < UPTO_REJECTION_TTL_SECONDS: + return None # this gateway recently refused upto from this wallet + resource = details.get("resource") or {} + try: + resource_url = validate_resource_url( + resource.get("url", f"{client.api_url}/v1/chat/completions"), client.api_url + ) + except Exception: + return None # the exact path raises the same error, unchanged + return option, { + "resource_url": resource_url, + "resource_description": resource.get("description", "BlockRun AI API call"), + "within_limits": _within_spend_limits(client, body), + } + + +def _upto_chat_payment( + client: Any, + body: dict[str, Any], + payment_required: dict[str, Any], + price_info: dict[str, Any], + details: dict[str, Any], + option: UptoOption, + signed: UptoPayment, +) -> _ChatPayment: + _warn_if_clamped(body, (details.get("resource") or {}).get("description")) + return _ChatPayment( + _payment_headers(signed.header), + signed.ceiling_usd, + "upto", + signed.amount, + network=option.network, + exact_fallback=lambda: _exact_chat_payment( + client, body, payment_required, price_info, details, upto_refused=True + ), + ) + + +def _exact_chat_payment( + client: Any, + body: dict[str, Any], + payment_required: dict[str, Any], + price_info: dict[str, Any], + details: dict[str, Any], + *, + upto_refused: bool = False, +) -> _ChatPayment: + """Sign the exact (EIP-3009) requirement — the path every release has used. + + ``upto_refused``: called as the fallback after the gateway refused an upto + payment for this same 402 (only the wording of the upto-only refusal). + """ + if details.get("scheme") == "upto": + # Chat asks extract_payment_details for the upto entry when no exact + # one exists (allow_upto=True). Signing an upto requirement as EIP-3009 + # would authorize the whole upto CEILING as a fixed transfer, so refuse + # instead — with a message that says why upto did not happen. + if upto_refused: + raise PaymentError( + "This 402 offers only the x402 'upto' scheme. An upto payment was " + "signed and sent, and the gateway refused it before serving the " + "request; there is no exact option to fall back to, so no exact " + "payment was signed." + ) + raise PaymentError( + "This 402 offers only the x402 'upto' scheme, and upto could not be used " + "here (payment_scheme='exact', no Permit2 allowance and no gas " + "sponsoring, insufficient balance, a ceiling over a spend limit, or the " + "gateway refused an upto payment from this wallet in the last " + f"{UPTO_REJECTION_TTL_SECONDS / 60:.0f} minutes). Nothing was signed." + ) + try: + signed_usd = int(str(details.get("amount", 0))) / 1e6 + except ValueError: + signed_usd = 0.0 + # A gateway offering upto quotes its `price` at the upto CEILING, which is + # not what exact signs; book exact on its own amount then. + cost_usd = ( + float(price_info.get("amount", 0)) + if price_info and not offers_upto(payment_required) + else signed_usd + ) + # Before signing, and on what is SIGNED: the body's `price` is the base + # price without the transaction fee, so capping on it would let a call + # through that signs slightly more than the limit. A refused quote is + # never sent, so nothing settles. + _enforce_spend_limits( + client, max(cost_usd, signed_usd), body.get("model") if isinstance(body, dict) else None + ) + + # 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( + account=client.account, + recipient=details["recipient"], + amount=details["amount"], + scheme=details.get("scheme"), + network=details.get("network", "eip155:84532" if client.is_testnet() else "eip155:8453"), + resource_url=validate_resource_url( + resource.get("url", f"{client.api_url}/v1/chat/completions"), client.api_url + ), + resource_description=resource.get("description", "BlockRun AI API call"), + max_timeout_seconds=details.get("maxTimeoutSeconds", 300), + extra=details.get("extra"), + extensions=extensions, + asset=details.get("asset"), + ) + try: + amount_micro = int(str(details.get("amount", 0))) + except ValueError: + amount_micro = 0 + return _ChatPayment(_payment_headers(payment_payload), cost_usd, "exact", amount_micro) + + +def _booked_cost( + payment: _ChatPayment, settlement: dict[str, Any] | None +) -> tuple[float, str | None]: + """What to record as spent for a paid call, and on what basis. + + exact → the quote, basis ``None`` (records unchanged from earlier releases). + upto → the settled amount when ``PAYMENT-RESPONSE`` reports one + (``"upto_settled"``); otherwise the signed ceiling, labeled + ``"upto_ceiling"`` — an upper bound, not a confirmed charge. + """ + if payment.scheme != "upto": + return payment.cost_usd, None + settled = settled_upto_amount(settlement, payment.amount_micro) + if settled is not None: + return settled / 1e6, "upto_settled" + return payment.cost_usd, "upto_ceiling" + + 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`` @@ -396,6 +791,7 @@ def __init__( transaction_log: bool | str | os.PathLike[str] | None = None, max_cost_per_call: float | None = None, max_session_cost: float | None = None, + payment_scheme: str | None = None, ): """ Initialize the BlockRun LLM client. @@ -415,6 +811,17 @@ def __init__( ``transactions.jsonl`` (model, input, output, cost_usd, tx_hash, on-chain amount, payer, payee, network) and writes a pretty-printed JSON file next to it. + payment_scheme: ``"auto"`` (default) or ``"exact"``. With ``"auto"``, a chat + call whose 402 also offers x402 ``upto`` pays with a Permit2 + ceiling and is settled at the ACTUAL cost after the call + (prompt-cache discounts included) — but only when the wallet + already has a Permit2 allowance or the gateway sponsors the + approval gaslessly; otherwise, or on any error, it signs + ``exact`` as before. The actual cost can be MORE than the + exact quote, which prices output at a tenth of + ``max_tokens``; it is never more than the ceiling (the full + ``max_tokens``). ``"exact"`` never signs upto and pays the + fixed quote. ``None`` honors ``BLOCKRUN_PAYMENT_SCHEME``. Raises: ValueError: If no wallet is configured. For agent use, call setup_agent_wallet() first. @@ -482,6 +889,9 @@ def __init__( # Session spending tracking self._session_total_usd: float = 0.0 + # The part of _session_total_usd that is an upto CEILING the gateway + # did not report a settled amount for: an upper bound, not a charge. + self._session_ceiling_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. @@ -489,6 +899,12 @@ def __init__( max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL" ) self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") + # x402 scheme preference (see x402_upto). Resolved eagerly so a typo in + # the argument or BLOCKRUN_PAYMENT_SCHEME fails at construction. + self._payment_scheme = resolve_payment_scheme(payment_scheme) + # (wallet, network) -> monotonic time the gateway last rejected an upto + # payment: that pair goes straight to exact for UPTO_REJECTION_TTL_SECONDS. + self._upto_rejected: dict[tuple[str, str], float] = {} self._session_calls: int = 0 self._last_call_cost: float = 0.0 @@ -719,7 +1135,10 @@ def get_spending(self) -> dict[str, Any]: Get current session spending. Returns: - Dict with total_usd and calls count + Dict with ``total_usd``, ``calls`` and ``ceiling_usd``. ``ceiling_usd`` + is the part of ``total_usd`` booked at an x402 ``upto`` CEILING because + the gateway reported no settled amount: an upper bound on what those + calls cost, not a confirmed charge (0.0 when every call paid ``exact``). Example: spending = client.get_spending() @@ -728,8 +1147,21 @@ def get_spending(self) -> dict[str, Any]: return { "total_usd": self._session_total_usd, "calls": self._session_calls, + "ceiling_usd": self._session_ceiling_usd, } + def _book_paid_call( + self, payment: _ChatPayment, settlement: dict[str, Any] | None + ) -> tuple[float, str | None]: + """Record a paid call in the session totals; returns ``_booked_cost``'s pair.""" + cost_usd, basis = _booked_cost(payment, settlement) + self._session_calls += 1 + self._session_total_usd += cost_usd + if basis == "upto_ceiling": + self._session_ceiling_usd += cost_usd + self._last_call_cost = cost_usd + return cost_usd, basis + def chat( self, model: str, @@ -1120,8 +1552,7 @@ def _stream_with_payment( timeout = self.search_timeout if is_search else self.timeout # ----- Phase 1: probe (no payment header) ----- - payment_headers: dict[str, str] | None = None - cost_usd = 0.0 + payment: _ChatPayment | None = None backoffs = self._STREAM_5XX_BACKOFFS for attempt in range(len(backoffs) + 1): @@ -1137,7 +1568,7 @@ def _stream_with_payment( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(resp1, self.api_key) - payment_headers, cost_usd = self._sign_payment_from_response(body, resp1) + payment = self._sign_chat_payment(body, resp1) break # advance to phase 2 if resp1.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): import time @@ -1155,9 +1586,11 @@ def _stream_with_payment( # 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 + assert payment is not None # break implies signing succeeded try: - yield from self._stream_paid_phase(url, body, payment_headers, cost_usd, timeout) + yield from self._stream_paid_phase( + url, body, payment.headers, payment.cost_usd, timeout, payment=payment + ) except (httpx.HTTPError, APIError) as exc: _mark_settled(exc) raise @@ -1169,27 +1602,48 @@ def _stream_paid_phase( payment_headers: dict[str, str], cost_usd: float, timeout: float | None, + *, + payment: _ChatPayment | None = None, + rejected: httpx.Response | None = None, ) -> Iterator[ChatCompletionChunk]: - """Phase 2 of :meth:`_stream_with_payment`: the paid, already-settled leg.""" + """Phase 2 of :meth:`_stream_with_payment`: the paid, already-settled leg. + + ``rejected`` is set on the one exact retry after the gateway refused an + upto payment: if exact is refused too, that original refusal surfaces. + """ + if payment is None: + payment = _ChatPayment(payment_headers, cost_usd, "exact", 0) + upto_refusal: httpx.Response | None = None 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 + "POST", url, json=body, headers=payment.headers, timeout=timeout ) as resp2: if resp2.status_code == 200: + basis: str | None = None 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, streaming=True) + # A stream's PAYMENT-RESPONSE arrives before the upto + # settle, so an upto stream books its ceiling, labeled, + # whatever amount that header carries. + settlement = self._capture_settlement(resp2) + cost_usd, basis = self._book_paid_call( + payment, None if payment.scheme == "upto" else settlement + ) + yield from self._iter_and_archive( + resp2, + body, + cost_usd, + streaming=True, + payment_scheme=payment.scheme, + cost_basis=basis, + ) return resp2.read() - if resp2.status_code == 402: - # Account rail: a 402 is the account being out of credit, not a - # challenge to sign. Nothing here can sign, so say so plainly. - raise_for_api_key_402(resp2, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + if _is_payment_rejection(resp2): + if _may_fall_back_to_exact(payment, resp2, replayed=attempt > 0): + upto_refusal = resp2 # nothing streamed yet: retry exact + break + self._raise_payment_rejection(rejected if rejected is not None else resp2) if resp2.status_code in self._STREAM_5XX_STATUSES and attempt < len(backoffs): import time @@ -1197,6 +1651,28 @@ def _stream_paid_phase( continue self._raise_stream_error(resp2, after_payment=True) + if upto_refusal is not None: + exact = _exact_after_upto_rejection(self, payment) + yield from self._stream_paid_phase( + url, + body, + exact.headers, + exact.cost_usd, + timeout, + payment=exact, + rejected=upto_refusal, + ) + + def _raise_payment_rejection(self, response: httpx.Response) -> NoReturn: + """Raise for a paid request the gateway refused to accept payment for.""" + if response.status_code == 402: + # Account rail: a 402 is the account being out of credit, not a + # challenge to sign. Nothing here can sign, so say so plainly. + raise_for_api_key_402(response, self.api_key) + raise _payment_rejected_error(response) + self._raise_stream_error(response, after_payment=True) + raise AssertionError("unreachable: _raise_stream_error always raises") + def _iter_and_archive( self, response: httpx.Response, @@ -1204,6 +1680,8 @@ def _iter_and_archive( cost_usd: float, *, streaming: bool = True, + payment_scheme: str | None = None, + cost_basis: str | None = None, ) -> Iterator[ChatCompletionChunk]: """Yield each SSE chunk, accumulate content for the local archive, then once ``data: [DONE]`` arrives ``save_to_cache`` the assembled @@ -1242,6 +1720,11 @@ def _iter_and_archive( # (e.g. the blockrun-litellm adapter) read it off the chunk to # report the real wallet deduction instead of a list-price estimate. chunk.cost_usd = cost_usd + if payment_scheme is not None: + # upto: cost_usd is the signed ceiling unless the gateway + # reported the settled amount — say which. + setattr(chunk, "payment_scheme", payment_scheme) # noqa: B010 (extra field) + setattr(chunk, "cost_is_ceiling", cost_basis == "upto_ceiling") # noqa: B010 yield chunk # Stream complete (saw [DONE]). Free models have cost_usd == 0; only @@ -1274,12 +1757,15 @@ def _iter_and_archive( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) except Exception: # Logging never breaks the call. pass - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) @staticmethod def _iter_sse_chunks(response: httpx.Response) -> Iterator[ChatCompletionChunk]: @@ -1307,6 +1793,28 @@ def _iter_sse_chunks(response: httpx.Response) -> Iterator[ChatCompletionChunk]: # model construction to avoid silently dropping output. yield ChatCompletionChunk.model_construct(**chunk_dict) + def _post_paid( + self, + url: str, + body: dict[str, Any], + headers: dict[str, str], + timeout: float | None, + ) -> tuple[httpx.Response, bool]: + """The paid POST, with one automatic retry on 502/503. + + Returns ``(response, replayed)``. ``replayed`` means the same signed + payment went out twice, so a rejection of the second send may only be + the first one having settled (nonce used): see _may_fall_back_to_exact. + """ + response = self._client.post(url, json=body, headers=headers, timeout=timeout) + if response.status_code in (502, 503): + import time + + time.sleep(1) + response = self._client.post(url, json=body, headers=headers, timeout=timeout) + return response, True + return response, False + def _sign_payment_from_response( self, body: dict[str, Any], @@ -1316,65 +1824,31 @@ def _sign_payment_from_response( Extract a 402's payment requirements, sign locally, and return ``(headers_with_PAYMENT_SIGNATURE, cost_usd)``. - Mirrors the inline signing logic in :meth:`_handle_payment_and_retry` - but returns the signed headers instead of doing the retry POST — - which lets the streaming path open an SSE connection for the retry. + Kept for compatibility; see :meth:`_sign_chat_payment`, which also says + which scheme was signed (for ``upto``, ``cost_usd`` is the ceiling). """ - payment_header = response.headers.get("payment-required") - price_info: dict[str, Any] = {} - if not payment_header: - try: - resp_body = response.json() - if "x402" in resp_body: - payment_header = resp_body - price_info = resp_body.get("price", {}) - except Exception: - pass - - if not payment_header: - raise PaymentError("402 response but no payment requirements found") - - if isinstance(payment_header, str): - payment_required = parse_payment_required(payment_header) - else: - payment_required = payment_header - - details = extract_payment_details(payment_required) - - cost_usd = ( - float(price_info.get("amount", 0)) - 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) + payment = self._sign_chat_payment(body, response) + return payment.headers, payment.cost_usd - 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, - recipient=details["recipient"], - amount=details["amount"], - network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), - resource_url=validate_resource_url( - resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url - ), - resource_description=resource.get("description", "BlockRun AI API call"), - max_timeout_seconds=details.get("maxTimeoutSeconds", 300), - extra=details.get("extra"), - extensions=extensions, - asset=details.get("asset"), - ) + def _sign_chat_payment(self, body: dict[str, Any], response: httpx.Response) -> _ChatPayment: + """Sign a chat 402: ``upto`` when the policy in :mod:`blockrun_llm.x402_upto` + allows it, else ``exact`` exactly as before. Returns the paid retry's + headers and what to book. - return ( - { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": payment_payload, - }, - cost_usd, - ) + SECURITY: Payment signing happens entirely on your machine. + Only the signature is sent - your private key never leaves. + """ + payment_required, price_info = _read_payment_required(response) + details = extract_payment_details(payment_required, allow_upto=True) + offer = _upto_offer(self, body, payment_required, details) + if offer is not None: + option, kwargs = offer + signed = try_upto_payment(self.account, option, payment_required, **kwargs) + if signed is not None: + return _upto_chat_payment( + self, body, payment_required, price_info, details, option, signed + ) + return _exact_chat_payment(self, body, payment_required, price_info, details) @staticmethod def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> None: @@ -1458,91 +1932,30 @@ def _handle_payment_and_retry( SECURITY: Payment signing happens entirely on your machine. Only the signature is sent - your private key never leaves. """ - # Get payment required header (x402 library uses lowercase) - payment_header = response.headers.get("payment-required") - price_info = {} - if not payment_header: - # Try to get from response body - try: - resp_body = response.json() - if "x402" in resp_body: - payment_header = resp_body - # Extract price info for spending report - price_info = resp_body.get("price", {}) - except Exception: - pass - - if not payment_header: - raise PaymentError("402 response but no payment requirements found") - - # Parse payment requirements - if isinstance(payment_header, str): - payment_required = parse_payment_required(payment_header) - else: - payment_required = payment_header - - # Extract payment details - details = extract_payment_details(payment_required) - - # Get the cost being paid - cost_usd = ( - float(price_info.get("amount", 0)) - 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 - 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( - account=self.account, - recipient=details["recipient"], - amount=details["amount"], - network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), - resource_url=validate_resource_url( - resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url - ), - resource_description=resource.get("description", "BlockRun AI API call"), - max_timeout_seconds=details.get("maxTimeoutSeconds", 300), - extra=details.get("extra"), - extensions=extensions, - asset=details.get("asset"), - ) + payment = self._sign_chat_payment(body, response) # Retry with payment (x402 library expects PAYMENT-SIGNATURE header) # Use longer timeout for Live Search requests is_search_request = "search_parameters" in body or body.get("search") is True request_timeout = self.search_timeout if is_search_request else self.timeout - payment_headers = { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": payment_payload, - } - - # Retry with payment, with one automatic retry on 502/503 - retry_response = self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout - ) - if retry_response.status_code in (502, 503): - import time - - time.sleep(1) - retry_response = self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout - ) + retry_response, replayed = self._post_paid(url, body, payment.headers, request_timeout) + if _may_fall_back_to_exact(payment, retry_response, replayed=replayed): + # The gateway refused the upto payment before serving anything: + # one retry with the same 402's exact requirement. If exact is + # refused too, the original refusal is what surfaces. + rejected = retry_response + payment = _exact_after_upto_rejection(self, payment) + retry_response, _ = self._post_paid(url, body, payment.headers, request_timeout) + if _is_payment_rejection(retry_response): + retry_response = rejected # Check for errors if retry_response.status_code == 402: # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -1560,11 +1973,10 @@ def _handle_payment_and_retry( response_data = retry_response.json() chat_response = ChatResponse(**response_data) - # Update session spending - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd + # Update session spending. For upto this is the settled amount when + # PAYMENT-RESPONSE reports one, else the signed ceiling, labeled. settlement = self._capture_settlement(retry_response) + cost_usd, cost_basis = self._book_paid_call(payment, settlement) # Attach the real x402 charge (and on-chain settlement) to THIS response # object so callers get a per-call, race-free cost. Use the value @@ -1572,6 +1984,8 @@ def _handle_payment_and_retry( # (shared state a concurrent call on the same client could overwrite), # and a local cost_usd rather than self._last_call_cost which goes stale. chat_response.cost_usd = cost_usd + chat_response.payment_scheme = payment.scheme + chat_response.cost_is_ceiling = cost_basis == "upto_ceiling" if settlement: chat_response.settlement = dict(settlement) @@ -1583,9 +1997,12 @@ def _handle_payment_and_retry( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) return chat_response @@ -1693,6 +2110,7 @@ def _handle_payment_and_retry_raw( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -1724,7 +2142,7 @@ def _handle_payment_and_retry_raw( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -1843,6 +2261,7 @@ def _handle_get_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -1872,7 +2291,7 @@ def _handle_get_payment_and_retry( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -2518,6 +2937,8 @@ def _log_transaction( body: dict[str, Any], response: Any, cost_usd: float, + *, + cost_basis: str | None = None, ) -> None: """Append one row to the project-local transaction log, if enabled. @@ -2542,6 +2963,7 @@ def _log_transaction( network=_detect_network(self.api_url), client_kind=type(self).__name__, settlement=settlement, + cost_basis=cost_basis, ) except Exception: pass @@ -2569,19 +2991,11 @@ def get_balance(self) -> float: # USDC contracts # Mainnet: Base # Testnet: Base Sepolia - if self.is_testnet(): - usdc_contract = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - rpcs = [ - "https://sepolia.base.org", - "https://base-sepolia-rpc.publicnode.com", - ] - else: - usdc_contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - rpcs = [ - "https://base.publicnode.com", - "https://mainnet.base.org", - "https://base.meowrpc.com", - ] + # Contract + public RPCs from the shared chain table (x402.EVM_NETWORKS), + # which the upto scheme's allowance reads use too. + net = EVM_NETWORKS["eip155:84532" if self.is_testnet() else "eip155:8453"] + usdc_contract = net["usdc"] + rpcs = list(net["rpcs"]) # balanceOf(address) function selector selector = "0x70a08231" @@ -2649,6 +3063,7 @@ def __init__( transaction_log: bool | str | os.PathLike[str] | None = None, max_cost_per_call: float | None = None, max_session_cost: float | None = None, + payment_scheme: str | None = None, ): """ Initialize the async BlockRun LLM client. @@ -2663,6 +3078,17 @@ def __init__( ``./log/``; pass a string/Path for a custom dir; ``None`` honors the ``BLOCKRUN_TX_LOG`` env var. See ``LLMClient`` for the full record schema. + payment_scheme: ``"auto"`` (default) or ``"exact"``. With ``"auto"``, a chat + call whose 402 also offers x402 ``upto`` pays with a Permit2 + ceiling and is settled at the ACTUAL cost after the call + (prompt-cache discounts included) — but only when the wallet + already has a Permit2 allowance or the gateway sponsors the + approval gaslessly; otherwise, or on any error, it signs + ``exact`` as before. The actual cost can be MORE than the + exact quote, which prices output at a tenth of + ``max_tokens``; it is never more than the ceiling (the full + ``max_tokens``). ``"exact"`` never signs upto and pays the + fixed quote. ``None`` honors ``BLOCKRUN_PAYMENT_SCHEME``. Raises: ValueError: If no wallet is configured @@ -2734,6 +3160,12 @@ def __init__( max_cost_per_call, "BLOCKRUN_MAX_COST_PER_CALL" ) self._max_session_cost = resolve_spend_limit(max_session_cost, "BLOCKRUN_MAX_SESSION_COST") + # x402 scheme preference (see x402_upto). Resolved eagerly so a typo in + # the argument or BLOCKRUN_PAYMENT_SCHEME fails at construction. + self._payment_scheme = resolve_payment_scheme(payment_scheme) + # (wallet, network) -> monotonic time the gateway last rejected an upto + # payment: that pair goes straight to exact for UPTO_REJECTION_TTL_SECONDS. + self._upto_rejected: dict[tuple[str, str], float] = {} log_dir = _resolve_log_dir(transaction_log) self._tx_logger: TransactionLogger | None = ( @@ -3126,8 +3558,7 @@ async def _stream_with_payment( statuses_5xx = LLMClient._STREAM_5XX_STATUSES # ----- Phase 1: probe (no payment header) ----- - payment_headers: dict[str, str] | None = None - cost_usd = 0.0 + payment: _ChatPayment | None = None for attempt in range(len(backoffs) + 1): async with self._client.stream( @@ -3142,7 +3573,7 @@ async def _stream_with_payment( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(resp1, self.api_key) - payment_headers, cost_usd = self._sign_payment_from_response(body, resp1) + payment = await self._asign_chat_payment(body, resp1) break if resp1.status_code in statuses_5xx and attempt < len(backoffs): import asyncio @@ -3155,13 +3586,15 @@ async def _stream_with_payment( # ----- Phase 2: stream with PAYMENT-SIGNATURE ----- # Settled from here on; see the sync path. - assert payment_headers is not None + assert payment 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) + paid = self._astream_paid_phase( + url, body, payment.headers, payment.cost_usd, timeout, payment=payment + ) try: async for chunk in paid: yield chunk @@ -3178,32 +3611,52 @@ async def _astream_paid_phase( payment_headers: dict[str, str], cost_usd: float, timeout: float | None, - ) -> AsyncIterator[ChatCompletionChunk]: - """Phase 2 of the async stream: the paid, already-settled leg.""" + *, + payment: _ChatPayment | None = None, + rejected: httpx.Response | None = None, + ) -> AsyncGenerator[ChatCompletionChunk, None]: + """Phase 2 of the async stream: the paid, already-settled leg. + + Same one-shot exact retry after an upto refusal as the sync path. + """ + if payment is None: + payment = _ChatPayment(payment_headers, cost_usd, "exact", 0) + upto_refusal: httpx.Response | None = None 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 + "POST", url, json=body, headers=payment.headers, timeout=timeout ) as resp2: if resp2.status_code == 200: # AsyncLLMClient only tracks ``_last_call_cost`` (no session # totals in the async path — matches the existing async # chat_completion convention). + basis: str | None = None if cost_usd > 0: + # Same as the sync stream: an upto ceiling, never the + # pre-settle header's amount. + settlement = self._capture_settlement(resp2) + cost_usd, basis = _booked_cost( + payment, None if payment.scheme == "upto" else settlement + ) self._last_call_cost = cost_usd - self._capture_settlement(resp2) async for chunk in self._aiter_and_archive( - resp2, body, cost_usd, streaming=True + resp2, + body, + cost_usd, + streaming=True, + payment_scheme=payment.scheme, + cost_basis=basis, ): yield chunk return await resp2.aread() - if resp2.status_code == 402: - # Account rail: a 402 is the account being out of credit, not a - # challenge to sign. Nothing here can sign, so say so plainly. - raise_for_api_key_402(resp2, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + if _is_payment_rejection(resp2): + if _may_fall_back_to_exact(payment, resp2, replayed=attempt > 0): + upto_refusal = resp2 # nothing streamed yet: retry exact + break + self._raise_payment_rejection(rejected if rejected is not None else resp2) if resp2.status_code in statuses_5xx and attempt < len(backoffs): import asyncio @@ -3211,6 +3664,23 @@ async def _astream_paid_phase( continue self._raise_stream_error(resp2, after_payment=True) + if upto_refusal is not None: + exact = _exact_after_upto_rejection(self, payment) + retry = self._astream_paid_phase( + url, + body, + exact.headers, + exact.cost_usd, + timeout, + payment=exact, + rejected=upto_refusal, + ) + try: + async for chunk in retry: + yield chunk + finally: + await retry.aclose() + async def _aiter_and_archive( self, response: httpx.Response, @@ -3218,6 +3688,8 @@ async def _aiter_and_archive( cost_usd: float, *, streaming: bool = True, + payment_scheme: str | None = None, + cost_basis: str | None = None, ) -> AsyncIterator[ChatCompletionChunk]: """Async mirror of :meth:`LLMClient._iter_and_archive`. Writes the assembled ``chat.completion`` response to ``~/.blockrun/data/`` and @@ -3250,6 +3722,11 @@ async def _aiter_and_archive( usage_dict = _usage # Race-free per-call x402 charge — see LLMClient._iter_and_archive. chunk.cost_usd = cost_usd + if payment_scheme is not None: + # upto: cost_usd is the signed ceiling unless the gateway + # reported the settled amount — say which. + setattr(chunk, "payment_scheme", payment_scheme) # noqa: B010 (extra field) + setattr(chunk, "cost_is_ceiling", cost_basis == "upto_ceiling") # noqa: B010 yield chunk if cost_usd > 0: @@ -3280,11 +3757,14 @@ async def _aiter_and_archive( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) except Exception: pass - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) @staticmethod async def _aiter_sse_chunks(response: httpx.Response) -> AsyncIterator[ChatCompletionChunk]: @@ -3306,8 +3786,48 @@ async def _aiter_sse_chunks(response: httpx.Response) -> AsyncIterator[ChatCompl # Reuse the sync helpers — Python class-attribute lookup binds them # correctly to whatever self is passed when the bound method is called. + # Sync signers kept for compatibility. They read chain state with blocking + # I/O when upto is offered, so this client's own paths use + # _asign_chat_payment instead. _sign_payment_from_response = LLMClient._sign_payment_from_response + _sign_chat_payment = LLMClient._sign_chat_payment + + async def _apost_paid( + self, + url: str, + body: dict[str, Any], + headers: dict[str, str], + timeout: float | None, + ) -> tuple[httpx.Response, bool]: + """Async :meth:`LLMClient._post_paid`: ``(response, replayed)``.""" + response = await self._client.post(url, json=body, headers=headers, timeout=timeout) + if response.status_code in (502, 503): + import asyncio + + await asyncio.sleep(1) + response = await self._client.post(url, json=body, headers=headers, timeout=timeout) + return response, True + return response, False + + async def _asign_chat_payment( + self, body: dict[str, Any], response: httpx.Response + ) -> _ChatPayment: + """Async :meth:`LLMClient._sign_chat_payment`: the upto chain reads do not + block the event loop.""" + payment_required, price_info = _read_payment_required(response) + details = extract_payment_details(payment_required, allow_upto=True) + offer = _upto_offer(self, body, payment_required, details) + if offer is not None: + option, kwargs = offer + signed = await atry_upto_payment(self.account, option, payment_required, **kwargs) + if signed is not None: + return _upto_chat_payment( + self, body, payment_required, price_info, details, option, signed + ) + return _exact_chat_payment(self, body, payment_required, price_info, details) + _raise_stream_error = LLMClient._raise_stream_error + _raise_payment_rejection = LLMClient._raise_payment_rejection async def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> ChatResponse: """Make async request with automatic payment handling.""" @@ -3358,84 +3878,32 @@ async def _handle_payment_and_retry( response: httpx.Response, ) -> ChatResponse: """Handle 402 response asynchronously.""" - # Get payment required header (x402 library uses lowercase) - payment_header = response.headers.get("payment-required") - if not payment_header: - try: - resp_body = response.json() - if "x402" in resp_body: - payment_header = resp_body - except Exception: - pass - - if not payment_header: - raise PaymentError("402 response but no payment requirements found") - - if isinstance(payment_header, str): - payment_required = parse_payment_required(payment_header) - else: - payment_required = payment_header - - 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 {} - _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( - account=self.account, - recipient=details["recipient"], - amount=details["amount"], - network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), - resource_url=validate_resource_url( - resource.get("url", f"{self.api_url}/v1/chat/completions"), self.api_url - ), - resource_description=resource.get("description", "BlockRun AI API call"), - max_timeout_seconds=details.get("maxTimeoutSeconds", 300), - extra=details.get("extra"), - extensions=extensions, - asset=details.get("asset"), - ) + # Signing prices the call: spend limits are enforced on the quote (the + # upto ceiling, for upto) before anything is signed or sent. + payment = await self._asign_chat_payment(body, response) # Retry with payment (x402 library expects PAYMENT-SIGNATURE header) # Use longer timeout for Live Search requests is_search_request = "search_parameters" in body or body.get("search") is True request_timeout = self.search_timeout if is_search_request else self.timeout - payment_headers = { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": payment_payload, - } - - # Retry with payment, with one automatic retry on 502/503 - retry_response = await self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout + retry_response, replayed = await self._apost_paid( + url, body, payment.headers, request_timeout ) - if retry_response.status_code in (502, 503): - import asyncio - - await asyncio.sleep(1) - retry_response = await self._client.post( - url, json=body, headers=payment_headers, timeout=request_timeout - ) + if _may_fall_back_to_exact(payment, retry_response, replayed=replayed): + # Upto refused before anything was served: one exact retry; if exact + # is refused too, the original refusal surfaces (see the sync path). + rejected = retry_response + payment = _exact_after_upto_rejection(self, payment) + retry_response, _ = await self._apost_paid(url, body, payment.headers, request_timeout) + if _is_payment_rejection(retry_response): + retry_response = rejected if retry_response.status_code == 402: # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -3449,25 +3917,18 @@ async def _handle_payment_and_retry( retry_after=retry_after_of(retry_response), ) - # Extract cost and save locally - price_info = {} - try: - resp_body = response.json() - price_info = resp_body.get("price", {}) - except Exception: - pass - cost_usd = ( - float(price_info.get("amount", 0)) - if price_info - else float(details.get("amount", 0)) / 1e6 - ) - self._last_call_cost = cost_usd + # Book the call: the quote for exact; for upto the settled amount when + # PAYMENT-RESPONSE reports one, else the signed ceiling, labeled. settlement = self._capture_settlement(retry_response) + cost_usd, cost_basis = _booked_cost(payment, settlement) + self._last_call_cost = cost_usd response_data = retry_response.json() # Per-call real charge + settlement (see sync _handle_payment_and_retry). chat_response = ChatResponse(**response_data) chat_response.cost_usd = cost_usd + chat_response.payment_scheme = payment.scheme + chat_response.cost_is_ceiling = cost_basis == "upto_ceiling" if settlement: chat_response.settlement = dict(settlement) from .cache import save_to_cache @@ -3477,9 +3938,12 @@ async def _handle_payment_and_retry( body, response_data, cost_usd=cost_usd, + cost_basis=cost_basis, **self._billing_meta(), ) - self._log_transaction("/v1/chat/completions", body, response_data, cost_usd) + self._log_transaction( + "/v1/chat/completions", body, response_data, cost_usd, cost_basis=cost_basis + ) return chat_response @@ -3572,6 +4036,7 @@ async def _handle_payment_and_retry_raw( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -3603,7 +4068,7 @@ async def _handle_payment_and_retry_raw( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -3706,6 +4171,7 @@ async def _handle_get_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:84532" if self.is_testnet() else "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun AI API call"), @@ -3735,7 +4201,7 @@ async def _handle_get_payment_and_retry( # Account rail: a 402 is the account being out of credit, not a # challenge to sign. Nothing here can sign, so say so plainly. raise_for_api_key_402(retry_response, self.api_key) - raise PaymentError("Payment was rejected. Check your wallet balance.") + raise _payment_rejected_error(retry_response) if retry_response.status_code != 200: try: @@ -4120,6 +4586,8 @@ def _log_transaction( body: dict[str, Any], response: Any, cost_usd: float, + *, + cost_basis: str | None = None, ) -> None: """Async-client twin of :meth:`LLMClient._log_transaction`.""" logger = self._tx_logger @@ -4138,6 +4606,7 @@ def _log_transaction( network=_detect_network(self.api_url), client_kind=type(self).__name__, settlement=settlement, + cost_basis=cost_basis, ) except Exception: pass @@ -4165,19 +4634,11 @@ async def get_balance(self) -> float: # USDC contracts # Mainnet: Base # Testnet: Base Sepolia - if self.is_testnet(): - usdc_contract = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" - rpcs = [ - "https://sepolia.base.org", - "https://base-sepolia-rpc.publicnode.com", - ] - else: - usdc_contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - rpcs = [ - "https://base.publicnode.com", - "https://mainnet.base.org", - "https://base.meowrpc.com", - ] + # Contract + public RPCs from the shared chain table (x402.EVM_NETWORKS), + # which the upto scheme's allowance reads use too. + net = EVM_NETWORKS["eip155:84532" if self.is_testnet() else "eip155:8453"] + usdc_contract = net["usdc"] + rpcs = list(net["rpcs"]) # balanceOf(address) function selector selector = "0x70a08231" diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index 60c14bf..e9704f4 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -362,6 +362,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=validate_resource_url( resource.get("url", f"{self.api_url}/v1/images/generations"), self.api_url diff --git a/blockrun_llm/music.py b/blockrun_llm/music.py index e94b869..54bcfd1 100644 --- a/blockrun_llm/music.py +++ b/blockrun_llm/music.py @@ -252,6 +252,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}/v1/audio/generations"), resource_description=resource.get("description", "BlockRun Music Generation"), diff --git a/blockrun_llm/phone.py b/blockrun_llm/phone.py index 52db7dd..9b818d4 100644 --- a/blockrun_llm/phone.py +++ b/blockrun_llm/phone.py @@ -294,6 +294,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Phone"), diff --git a/blockrun_llm/portrait.py b/blockrun_llm/portrait.py index d31f46b..5e0f822 100644 --- a/blockrun_llm/portrait.py +++ b/blockrun_llm/portrait.py @@ -282,6 +282,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get( diff --git a/blockrun_llm/price.py b/blockrun_llm/price.py index ad24761..b01b140 100644 --- a/blockrun_llm/price.py +++ b/blockrun_llm/price.py @@ -300,6 +300,7 @@ def _pay_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Price Data"), diff --git a/blockrun_llm/realface.py b/blockrun_llm/realface.py index c9b9899..cd3446a 100644 --- a/blockrun_llm/realface.py +++ b/blockrun_llm/realface.py @@ -436,6 +436,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=validate_resource_url(resource.get("url", url), self.api_url), resource_description=resource.get("description", "BlockRun RealFace Enrollment"), diff --git a/blockrun_llm/rpc.py b/blockrun_llm/rpc.py index 66d970e..ae04e64 100644 --- a/blockrun_llm/rpc.py +++ b/blockrun_llm/rpc.py @@ -380,6 +380,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}{endpoint}"), resource_description=resource.get("description", "BlockRun Multi-chain RPC"), diff --git a/blockrun_llm/search.py b/blockrun_llm/search.py index 38df411..24df9f6 100644 --- a/blockrun_llm/search.py +++ b/blockrun_llm/search.py @@ -200,6 +200,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Search"), diff --git a/blockrun_llm/speech.py b/blockrun_llm/speech.py index 04bfd1b..c2a3ee8 100644 --- a/blockrun_llm/speech.py +++ b/blockrun_llm/speech.py @@ -331,6 +331,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}{endpoint}"), resource_description=resource.get("description", "BlockRun Voice"), diff --git a/blockrun_llm/surf.py b/blockrun_llm/surf.py index c0b5dc5..1307976 100644 --- a/blockrun_llm/surf.py +++ b/blockrun_llm/surf.py @@ -385,6 +385,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", url), resource_description=resource.get("description", "BlockRun Surf"), diff --git a/blockrun_llm/tx_log.py b/blockrun_llm/tx_log.py index 44ead88..caf425f 100644 --- a/blockrun_llm/tx_log.py +++ b/blockrun_llm/tx_log.py @@ -274,18 +274,27 @@ def format_row( out_tokens: int, cost_usd: float, tx_hash: str | None, + cost_basis: str | None = None, ) -> str: - """Format one log row exactly like the example in the module docstring.""" + """Format one log row exactly like the example in the module docstring. + + An x402 upto call booked at its signed ceiling (``cost_basis == + "upto_ceiling"``) gets a trailing ``(upto ceiling)`` marker: that dollar + figure is an upper bound, not the settled charge. + """ if ts is None: ts = time.time() when = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") tag = _endpoint_tag(endpoint) model_str = (model or "-")[:30].ljust(30) tx_str = f"{tx_hash[:10]}…" if tx_hash else "(no-tx)" - return ( + row = ( f"{when} {tag:<5} {model_str} " f"in={in_tokens:>5} out={out_tokens:<3} ${cost_usd:.6f} {tx_str}" ) + if cost_basis == "upto_ceiling": + row += " (upto ceiling)" + return row # --------------------------------------------------------------------------- @@ -328,6 +337,7 @@ def log( network: str | None = None, client_kind: str | None = None, settlement: dict[str, Any] | None = None, + cost_basis: str | None = None, ) -> Path | None: """Append one formatted row to ``./log/transactions.log``. @@ -349,6 +359,7 @@ def log( out_tokens=out_tokens, cost_usd=float(cost_usd or 0.0), tx_hash=tx_hash, + cost_basis=cost_basis, ) # Silence unused-arg warnings without changing the public API — the diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index bd02f0e..2d01bcf 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -144,6 +144,13 @@ class ChatResponse(BaseModel): # returned an X-PAYMENT-RESPONSE header. cost_usd: Optional[float] = None settlement: Optional[Dict[str, Any]] = None + # Which x402 scheme paid for this call: "exact" (the quote is what settles) + # or "upto" (a signed ceiling; the gateway settles the actual cost, prompt- + # cache discounts included). ``cost_is_ceiling`` is True when ``cost_usd`` + # is that CEILING because the gateway reported no settled amount — an upper + # bound, not the confirmed charge. Both None for free / cached calls. + payment_scheme: Optional[str] = None + cost_is_ceiling: Optional[bool] = None class Config: extra = "allow" diff --git a/blockrun_llm/video.py b/blockrun_llm/video.py index 3ec36d5..63a4511 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -514,6 +514,7 @@ def _sign_from_challenge(self, resp402: httpx.Response, fallback_url: str) -> st account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", fallback_url), resource_description=resource.get("description", "BlockRun Video Generation"), diff --git a/blockrun_llm/voice.py b/blockrun_llm/voice.py index 6ce2de0..dd11288 100644 --- a/blockrun_llm/voice.py +++ b/blockrun_llm/voice.py @@ -343,6 +343,7 @@ def _handle_payment_and_retry( account=self.account, recipient=details["recipient"], amount=details["amount"], + scheme=details.get("scheme"), network=details.get("network", "eip155:8453"), resource_url=resource.get("url", f"{self.api_url}/v1/voice/call"), resource_description=resource.get("description", "BlockRun Voice Call"), diff --git a/blockrun_llm/x402.py b/blockrun_llm/x402.py index af10d78..c025241 100644 --- a/blockrun_llm/x402.py +++ b/blockrun_llm/x402.py @@ -48,6 +48,14 @@ "chainId": BASE_CHAIN_ID, "verifyingContract": USDC_BASE, }, + # Public read-only RPCs, tried in order. Used for USDC balance reads + # (``get_balance``) and the upto scheme's Permit2 allowance / EIP-2612 + # nonce reads — never to send a transaction. + "rpcs": ( + "https://base.publicnode.com", + "https://mainnet.base.org", + "https://base.meowrpc.com", + ), }, "eip155:5042": { "name": "Arc", @@ -59,6 +67,10 @@ "chainId": ARC_CHAIN_ID, "verifyingContract": USDC_ARC, }, + # No public RPC is pinned for Arc yet, so the upto scheme (which must + # read Permit2 allowance before signing) never engages there: every + # Arc payment stays `exact`. + "rpcs": (), }, "eip155:84532": { "name": "Base Sepolia", @@ -70,6 +82,10 @@ "chainId": BASE_SEPOLIA_CHAIN_ID, "verifyingContract": USDC_BASE_SEPOLIA, }, + "rpcs": ( + "https://sepolia.base.org", + "https://base-sepolia-rpc.publicnode.com", + ), }, } # The pre-CAIP alias this SDK accepted for the testnet. @@ -122,6 +138,16 @@ def get_usdc_domain_name(network: str) -> str: return evm_network(network)["domain"]["name"] +def signature_hex(signed: Any) -> str: + """0x-prefixed hex of an eth-account signature, whichever hexbytes is installed. + + ``HexBytes.hex()`` dropped its ``0x`` prefix in hexbytes 1.0, so the prefix + is added only when it is missing. + """ + raw = str(signed.signature.hex()) + return raw if raw.startswith("0x") else "0x" + raw + + def create_nonce() -> str: """Generate a random bytes32 nonce.""" return "0x" + secrets.token_hex(32) @@ -138,6 +164,7 @@ def create_payment_payload( extra: dict[str, str] | None = None, extensions: dict[str, Any] | None = None, asset: str | None = None, + scheme: str | None = "exact", ) -> str: """ Create a signed x402 v2 payment payload. @@ -155,10 +182,20 @@ def create_payment_payload( max_timeout_seconds: Max timeout for the payment (default: 300) extra: The 402's `extra`. Accepted for compatibility; the domain comes from EVM_NETWORKS. asset: The 402's `asset`. Checked against the network's USDC; a mismatch raises ValueError. + scheme: The scheme of the requirement being paid (``details["scheme"]``). + This signs an EIP-3009 transfer, which is the ``exact`` scheme only; + anything else (e.g. an ``upto`` ceiling) raises ValueError, since it + would authorize that ceiling as a fixed transfer. ``None`` (a v1 + requirement without a scheme) is ``exact``. Returns: Base64-encoded signed payment payload """ + if scheme is not None and scheme != "exact": + raise ValueError( + f"x402 scheme {scheme!r} cannot be signed as an EIP-3009 (exact) transfer; " + "nothing was signed" + ) # Current timestamp now = int(time.time()) valid_after = now - 600 # 10 minutes before (allows for clock skew) @@ -223,11 +260,7 @@ def create_payment_payload( "extra": {"name": domain["name"], "version": domain["version"]}, }, "payload": { - "signature": ( - "0x" + signed.signature.hex() - if not signed.signature.hex().startswith("0x") - else signed.signature.hex() - ), + "signature": signature_hex(signed), "authorization": { "from": account.address, "to": recipient, @@ -262,7 +295,9 @@ def parse_payment_required(header_value: str) -> dict[str, Any]: raise ValueError("Failed to parse payment required header: invalid format") -def extract_payment_details(payment_required: dict[str, Any]) -> dict[str, Any]: +def extract_payment_details( + payment_required: dict[str, Any], *, allow_upto: bool = False +) -> dict[str, Any]: """ Extract payment details from parsed payment required response. @@ -270,16 +305,38 @@ def extract_payment_details(payment_required: dict[str, Any]) -> dict[str, Any]: Args: payment_required: Parsed payment required dict + allow_upto: Return the ``upto`` requirement when it is the only one + offered, instead of raising. Only for a caller that can pay upto + (chat, via :mod:`blockrun_llm.x402_upto`) and checks ``scheme`` + before signing anything exact. Returns: Dict with amount, recipient, network, asset, and extra info + + Raises: + ValueError: No options, no amount, or (unless ``allow_upto``) only + ``upto`` options, which an EIP-3009 signer must never sign: it + would authorize the whole upto ceiling as a fixed transfer. """ accepts = payment_required.get("accepts", []) if not accepts: raise ValueError("No payment options in payment required response") - # Take the first option - option = accepts[0] + # The first option this signer can pay with EIP-3009. A gateway that also + # offers `upto` (Permit2) lists `exact` first, but nothing obliges it to: + # an upto requirement signed as an EIP-3009 authorization is a signature + # the facilitator rejects. Upto is chosen separately, in x402_upto. + option = next( + (o for o in accepts if isinstance(o, dict) and o.get("scheme", "exact") != "upto"), + None, + ) + if option is None: + if not allow_upto: + raise ValueError( + "This 402 offers only the x402 'upto' scheme, which this endpoint's " + "signer cannot pay; nothing was signed" + ) + option = accepts[0] # Support both v1 (maxAmountRequired) and v2 (amount) formats amount = option.get("amount") or option.get("maxAmountRequired") diff --git a/blockrun_llm/x402_upto.py b/blockrun_llm/x402_upto.py new file mode 100644 index 0000000..90a906b --- /dev/null +++ b/blockrun_llm/x402_upto.py @@ -0,0 +1,848 @@ +""" +x402 ``upto`` scheme (Permit2) for BlockRun's EVM gateways. + +Under ``exact`` the payer signs an EIP-3009 authorization for a fixed pre-call +quote, so a discount the gateway only learns after the call — a prompt-cache +hit, a short answer — can never reach an x402 caller. Under ``upto`` the payer +signs a Permit2 ``PermitWitnessTransferFrom`` for a CEILING, and the gateway +settles the ACTUAL amount (never more than the ceiling) once the call is done. + +A gateway that offers upto lists it after ``exact`` in ``accepts``. This module +decides whether to take it, and signs it. + +Upto is NOT always cheaper than exact. BlockRun's exact quote prices output at a +fraction of ``max_tokens`` (``OUTPUT_QUOTE_FACTOR``, 0.1 today), so a response +that runs long settles for more under upto than exact would have charged; a +short or cache-hit response settles for less. Upto is bounded by its ceiling +(the full ``max_tokens``), never by the exact quote. Pass +``payment_scheme="exact"`` to keep paying the fixed quote. Upto is taken when: + +* It is taken only when the 402 offers an EVM upto option carrying + ``extra.facilitatorAddress`` for a network in :data:`EVM_NETWORKS`, and +* the wallet holds at least the ceiling in USDC, and +* Permit2 may already pull the ceiling (USDC ``allowance(owner, Permit2)``), or + the 402 declares the ``eip2612GasSponsoring`` extension, in which case the + wallet signs a gasless EIP-2612 permit to Permit2 and the facilitator submits + the approval — so a wallet holding no ETH can still use upto. + +Anything else — no RPC answer, a signing error, a ceiling over a spend limit, a +sponsored permit already in flight for this wallet — falls back to ``exact`` +silently (debug log only). Solana is untouched: it only ever pays ``exact``. + +The in-flight guards below (one sponsored permit per wallet and network, and +the preflight marker) are per PROCESS. Several processes paying from one wallet +can each sign a permit over the same USDC nonce, and all but one revert; nor is +the wallet's balance checked against the SUM of concurrent ceilings. Either +case fails a call rather than overcharging; run one paying process per wallet, +or pass ``payment_scheme="exact"``, to avoid it. + +Semantics match the official ``@x402/evm`` 2.28.0 client (``UptoEvmScheme``, +``createUptoPermit2Payload``, ``trySignEip2612PermitExtension``); the unit tests +pin a vector generated by that client (``scripts/gen-upto-vector.mjs``). + +The private key is used ONLY for local signing and NEVER leaves the client. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import re +import secrets +import threading +import time +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import Any, Callable + +import httpx +from eth_account.messages import encode_typed_data +from eth_utils.address import to_checksum_address + +from .x402 import ( + _NETWORK_ALIASES, + EVM_NETWORKS, + signature_hex, + with_builder_code_service_code, +) + +logger = logging.getLogger(__name__) + +# Canonical Uniswap Permit2 — the same address on every EVM chain. +PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" +# x402's upto proxy: the Permit2 `spender`. It only lets the facilitator bound +# in the witness settle, and only up to the permitted amount. +X402_UPTO_PERMIT2_PROXY_ADDRESS = "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002" + +EIP2612_GAS_SPONSORING_KEY = "eip2612GasSponsoring" +# The EIP-2612 permit approves Permit2 for exactly the per-call ceiling, as the +# official client does. Not MaxUint256: the upto proxy's `_executePermit` +# reverts with Permit2612AmountMismatch unless permit.value == permitted.amount, +# and CDP /verify rejects a MaxUint256 permit. +EIP2612_GAS_SPONSORING_VERSION = "1" + +# `payment_scheme` values a client accepts. "auto" prefers upto when the rule +# above allows it; "exact" never signs upto. +PAYMENT_SCHEMES = ("auto", "exact") +PAYMENT_SCHEME_ENV = "BLOCKRUN_PAYMENT_SCHEME" + +# Per-RPC timeout for the allowance / balance / nonce reads. These run on every +# upto-eligible call before signing, so they are kept short: a slow RPC falls +# through to the next one, and all of them failing falls back to exact. +RPC_TIMEOUT_SECONDS = 3.0 + +# ERC-20 / EIP-2612 view selectors. +_SEL_BALANCE_OF = "0x70a08231" # balanceOf(address) +_SEL_ALLOWANCE = "0xdd62ed3e" # allowance(address,address) +_SEL_NONCES = "0x7ecebe00" # nonces(address) + +_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") +_UINT_RE = re.compile(r"^[0-9]+$") + +UPTO_PERMIT2_WITNESS_TYPES: dict[str, list[dict[str, str]]] = { + "PermitWitnessTransferFrom": [ + {"name": "permitted", "type": "TokenPermissions"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + {"name": "witness", "type": "Witness"}, + ], + "TokenPermissions": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "Witness": [ + {"name": "to", "type": "address"}, + {"name": "facilitator", "type": "address"}, + {"name": "validAfter", "type": "uint256"}, + ], +} + +EIP2612_PERMIT_TYPES: dict[str, list[dict[str, str]]] = { + "Permit": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ], +} + + +class UptoUnavailable(Exception): + """Upto cannot be used for this call; the caller falls back to exact.""" + + +def resolve_payment_scheme(value: str | None) -> str: + """The client's ``payment_scheme``: the argument, else ``BLOCKRUN_PAYMENT_SCHEME``, else "auto". + + Raises: + ValueError: For anything other than "auto" or "exact". + """ + raw = value if value is not None else os.environ.get(PAYMENT_SCHEME_ENV) + scheme = (raw or "auto").strip().lower() + if scheme not in PAYMENT_SCHEMES: + source = "payment_scheme" if value is not None else PAYMENT_SCHEME_ENV + raise ValueError(f'{source} must be one of {", ".join(PAYMENT_SCHEMES)}; got "{raw}"') + return scheme + + +# --------------------------------------------------------------------------- +# Selecting the offer +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class UptoOption: + """A 402's upto requirement, validated against the SDK's own chain table.""" + + requirement: dict[str, Any] # the accepts entry, echoed back as `accepted` + network: str # canonical CAIP-2 + chain_id: int + usdc: str + domain: dict[str, Any] # USDC's EIP-712 domain (SDK-owned, never the 402's) + rpcs: tuple[str, ...] + amount: int # ceiling, micro-USDC + pay_to: str + facilitator: str + max_timeout_seconds: int + gas_sponsoring: bool # the 402 declared extensions.eip2612GasSponsoring + + @property + def ceiling_usd(self) -> float: + return self.amount / 1e6 + + +def gas_sponsoring_declared(payment_required: dict[str, Any]) -> bool: + """Whether the 402's top-level ``extensions`` declares ``eip2612GasSponsoring``.""" + extensions = payment_required.get("extensions") + return isinstance(extensions, dict) and bool(extensions.get(EIP2612_GAS_SPONSORING_KEY)) + + +def offers_upto(payment_required: dict[str, Any]) -> bool: + """Whether any ``accepts`` entry is an upto requirement (usable or not).""" + accepts = payment_required.get("accepts") + return isinstance(accepts, list) and any( + isinstance(o, dict) and o.get("scheme") == "upto" for o in accepts + ) + + +def find_upto_option(payment_required: dict[str, Any]) -> UptoOption | None: + """The first upto requirement this SDK can sign, or ``None``. + + Same trust rule as ``exact`` (see ``EVM_NETWORKS``): the 402 selects a + network the SDK knows and must ask for that network's USDC; the chain id and + token domain come from the SDK's table. ``extra.facilitatorAddress`` is + required — without it the witness cannot bind a settler, and the official + client refuses too. + """ + accepts = payment_required.get("accepts") + if not isinstance(accepts, list): + return None + sponsoring = gas_sponsoring_declared(payment_required) + for opt in accepts: + if not isinstance(opt, dict) or opt.get("scheme") != "upto": + continue + try: + option = _validate_upto_option(opt, sponsoring) + except (TypeError, ValueError) as exc: + logger.debug("x402 upto: ignoring upto offer (%s)", exc) + continue + return option + return None + + +def _validate_upto_option(opt: dict[str, Any], sponsoring: bool) -> UptoOption: + network = str(opt.get("network") or "") + net = EVM_NETWORKS.get(_NETWORK_ALIASES.get(network, network)) + if net is None: + raise ValueError(f"network {network!r} is not an EVM network this SDK signs on") + + asset = opt.get("asset") + if not isinstance(asset, str) or asset.lower() != net["usdc"].lower(): + raise ValueError(f"asset {asset!r} is not USDC on {network}") + + raw_extra = opt.get("extra") + extra: dict[str, Any] = raw_extra if isinstance(raw_extra, dict) else {} + facilitator = extra.get("facilitatorAddress") + if not isinstance(facilitator, str) or not _ADDRESS_RE.match(facilitator): + raise ValueError("no extra.facilitatorAddress") + + pay_to = opt.get("payTo") + if not isinstance(pay_to, str) or not _ADDRESS_RE.match(pay_to): + raise ValueError(f"payTo {pay_to!r} is not an address") + + raw_amount = opt.get("amount") or opt.get("maxAmountRequired") + if not _UINT_RE.match(str(raw_amount or "")) or int(str(raw_amount)) <= 0: + raise ValueError(f"amount {raw_amount!r} is not a positive integer") + + timeout = opt.get("maxTimeoutSeconds", 300) + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError(f"maxTimeoutSeconds {timeout!r} is not a positive integer") + + return UptoOption( + requirement=dict(opt), + network=_NETWORK_ALIASES.get(network, network), + chain_id=int(net["chain_id"]), + usdc=to_checksum_address(net["usdc"]), + domain=dict(net["domain"]), + rpcs=tuple(net.get("rpcs") or ()), + amount=int(str(raw_amount)), + pay_to=to_checksum_address(pay_to), + facilitator=to_checksum_address(facilitator), + max_timeout_seconds=timeout, + gas_sponsoring=sponsoring, + ) + + +# --------------------------------------------------------------------------- +# Chain reads (public RPC, eth_call only) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class UptoChainState: + balance: int # USDC balanceOf(owner) + allowance: int # USDC allowance(owner, Permit2) + permit_nonce: int | None # USDC nonces(owner); read only when a permit is needed + + +def _word(address: str) -> str: + return address[2:].lower().rjust(64, "0") + + +def _eth_call_body(to: str, data: str) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [{"to": to, "data": data}, "latest"], + } + + +def _parse_uint(payload: Any) -> int: + """A uint256 eth_call result, strictly: an RPC error or empty return raises. + + Strict on purpose — a nonce misread as 0 signs a permit the token rejects, + which would fail a call that ``exact`` would have paid for. + """ + if not isinstance(payload, dict) or payload.get("error"): + raise UptoUnavailable(f"eth_call error: {payload!r:.200}") + result = payload.get("result") + if not isinstance(result, str) or not result.startswith("0x") or len(result) <= 2: + raise UptoUnavailable(f"eth_call returned {result!r:.80}") + return int(result, 16) + + +def _state_calls(option: UptoOption, owner: str) -> tuple[tuple[str, str], tuple[str, str]]: + return ( + (option.usdc, _SEL_BALANCE_OF + _word(owner)), + (option.usdc, _SEL_ALLOWANCE + _word(owner) + _word(PERMIT2_ADDRESS)), + ) + + +def _nonce_call(option: UptoOption, owner: str) -> tuple[str, str]: + return option.usdc, _SEL_NONCES + _word(owner) + + +def _needs_permit_nonce(option: UptoOption, allowance: int) -> bool: + return option.gas_sponsoring and allowance < option.amount + + +def _read_state_sync( + http: httpx.Client, url: str, option: UptoOption, owner: str +) -> UptoChainState: + def call(to: str, data: str) -> int: + resp = http.post(url, json=_eth_call_body(to, data)) + resp.raise_for_status() + return _parse_uint(resp.json()) + + (b_to, b_data), (a_to, a_data) = _state_calls(option, owner) + balance = call(b_to, b_data) + allowance = call(a_to, a_data) + nonce = call(*_nonce_call(option, owner)) if _needs_permit_nonce(option, allowance) else None + return UptoChainState(balance, allowance, nonce) + + +async def _read_state_async( + http: httpx.AsyncClient, url: str, option: UptoOption, owner: str +) -> UptoChainState: + async def call(to: str, data: str) -> int: + resp = await http.post(url, json=_eth_call_body(to, data)) + resp.raise_for_status() + return _parse_uint(resp.json()) + + (b_to, b_data), (a_to, a_data) = _state_calls(option, owner) + balance = await call(b_to, b_data) + allowance = await call(a_to, a_data) + nonce = ( + await call(*_nonce_call(option, owner)) if _needs_permit_nonce(option, allowance) else None + ) + return UptoChainState(balance, allowance, nonce) + + +def read_upto_chain_state( + option: UptoOption, owner: str, *, timeout: float = RPC_TIMEOUT_SECONDS +) -> UptoChainState: + """Read balance, Permit2 allowance and (if a permit is needed) the EIP-2612 nonce. + + Tries each of the network's public RPCs in turn; raises + :class:`UptoUnavailable` when none answers. + """ + if not option.rpcs: + raise UptoUnavailable(f"no public RPC configured for {option.network}") + last: Exception | None = None + for url in option.rpcs: + try: + with httpx.Client(timeout=timeout) as http: + return _read_state_sync(http, url, option, owner) + except Exception as exc: # next RPC + last = exc + raise UptoUnavailable(f"every RPC for {option.network} failed: {last}") from last + + +async def aread_upto_chain_state( + option: UptoOption, owner: str, *, timeout: float = RPC_TIMEOUT_SECONDS +) -> UptoChainState: + """Async :func:`read_upto_chain_state` — the same reads, without blocking the loop.""" + if not option.rpcs: + raise UptoUnavailable(f"no public RPC configured for {option.network}") + last: Exception | None = None + for url in option.rpcs: + try: + async with httpx.AsyncClient(timeout=timeout) as http: + return await _read_state_async(http, url, option, owner) + except Exception as exc: # next RPC + last = exc + raise UptoUnavailable(f"every RPC for {option.network} failed: {last}") from last + + +# --------------------------------------------------------------------------- +# Deciding +# --------------------------------------------------------------------------- + +# Sponsored permits this process signed and has not yet seen consumed: +# (network, owner) → (USDC nonce signed over, permit deadline). A permit lands +# only when the facilitator settles the call that carried it, and it approves +# just that call's ceiling, so Permit2's allowance is back near 0 afterwards and +# every sponsored call needs a fresh permit. Until one lands the chain still +# shows the old USDC nonce, and a second call would sign another permit over the +# SAME nonce: the first settlement consumes it and the second reverts on-chain +# (seen live on Base mainnet). So per wallet and network only one gas-sponsored +# upto payment is in flight; a call that would need a second pays exact. +# +# Read on every sponsored preflight: on-chain nonce > signed nonce → the permit +# was consumed, clear; ≤ and before the deadline → still in flight, pay exact; +# past the deadline → it can never land, clear. +_PERMITS_IN_FLIGHT: dict[tuple[str, str], tuple[int, int]] = {} +_PERMITS_LOCK = threading.Lock() + + +def _now() -> float: + return time.time() + + +def _claim_permit_slot(network: str, owner: str, onchain_nonce: int, deadline: int) -> bool: + """Atomically: if no permit is in flight for this wallet+network, record one + over ``onchain_nonce`` (valid until ``deadline``) and return True.""" + key = (network, owner.lower()) + now = _now() + with _PERMITS_LOCK: + pending = _PERMITS_IN_FLIGHT.get(key) + if pending is not None: + signed_nonce, pending_deadline = pending + if onchain_nonce <= signed_nonce and now < pending_deadline: + return False + _PERMITS_IN_FLIGHT[key] = (onchain_nonce, deadline) + return True + + +def _release_permit_slot(network: str, owner: str, signed_nonce: int) -> None: + """Forget a permit that was never sent (signing failed).""" + key = (network, owner.lower()) + with _PERMITS_LOCK: + pending = _PERMITS_IN_FLIGHT.get(key) + if pending is not None and pending[0] == signed_nonce: + del _PERMITS_IN_FLIGHT[key] + + +# Wallets+networks with a permit PREFLIGHT in progress: a call that might sign +# a permit claims this marker before its chain read. Without it, concurrent +# calls in one client all read the same USDC nonce before any of them recorded +# a permit, and each signed one over it (seen live: 1 settled, 2 reverted). A +# call that finds the marker taken never signs a permit: it pays upto only if +# its own read shows Permit2's allowance already covers the ceiling, else +# exact. The marker is released when the preflight ends; a signed permit lives +# on in _PERMITS_IN_FLIGHT (the nonce record) instead. +_PERMIT_PREFLIGHT: set[tuple[str, str]] = set() + + +def _reserve_preflight(network: str, owner: str) -> bool: + """Atomically claim the permit-preflight marker. A threading.Lock with no + await inside, so it is atomic for threads and for coroutines alike.""" + key = (network, owner.lower()) + with _PERMITS_LOCK: + if key in _PERMIT_PREFLIGHT: + return False + _PERMIT_PREFLIGHT.add(key) + return True + + +def _release_preflight(network: str, owner: str) -> None: + with _PERMITS_LOCK: + _PERMIT_PREFLIGHT.discard((network, owner.lower())) + + +def plan_upto(option: UptoOption, state: UptoChainState) -> bool | None: + """``False`` = upto with no permit, ``True`` = upto with a sponsored permit, + ``None`` = fall back to exact.""" + if state.balance < option.amount: + logger.debug( + "x402 upto: USDC balance %s below ceiling %s; using exact", + state.balance, + option.amount, + ) + return None + if state.allowance >= option.amount: + return False + if option.gas_sponsoring and state.permit_nonce is not None: + return True + logger.debug( + "x402 upto: Permit2 allowance %s below ceiling %s and no gas sponsoring; using exact", + state.allowance, + option.amount, + ) + return None + + +# --------------------------------------------------------------------------- +# Signing +# --------------------------------------------------------------------------- + + +def permit2_witness_typed_data( + *, + chain_id: int, + token: str, + amount: int, + nonce: int, + deadline: int, + pay_to: str, + facilitator: str, + valid_after: int = 0, +) -> dict[str, Any]: + """The full EIP-712 message the upto payer signs (Permit2 domain, no version).""" + return { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + **UPTO_PERMIT2_WITNESS_TYPES, + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": chain_id, + "verifyingContract": PERMIT2_ADDRESS, + }, + "message": { + "permitted": {"token": token, "amount": amount}, + "spender": X402_UPTO_PERMIT2_PROXY_ADDRESS, + "nonce": nonce, + "deadline": deadline, + "witness": {"to": pay_to, "facilitator": facilitator, "validAfter": valid_after}, + }, + } + + +def eip2612_permit_typed_data( + *, domain: dict[str, Any], owner: str, value: int, nonce: int, deadline: int +) -> dict[str, Any]: + """The full EIP-712 message for a USDC ``permit(owner, Permit2, value, …)``.""" + return { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + **EIP2612_PERMIT_TYPES, + }, + "primaryType": "Permit", + "domain": dict(domain), + "message": { + "owner": owner, + "spender": PERMIT2_ADDRESS, + "value": value, + "nonce": nonce, + "deadline": deadline, + }, + } + + +def _sign(account: Any, full_message: dict[str, Any]) -> str: + return signature_hex(account.sign_message(encode_typed_data(full_message=full_message))) + + +def merge_extensions( + server: dict[str, Any] | None, client: dict[str, Any] | None +) -> dict[str, Any]: + """Port of ``x402Client.mergeExtensions`` (@x402/core): client data adds + fields, server-declared fields stay intact; a non-object replaces.""" + if not client: + return dict(server or {}) + if not server: + return dict(client) + merged = dict(server) + for key, client_value in client.items(): + server_value = merged.get(key) + if not isinstance(server_value, dict) or not isinstance(client_value, dict): + merged[key] = client_value + continue + merged[key] = _merge_server_wins(server_value, client_value) + return merged + + +def _merge_server_wins(server: dict[str, Any], client: dict[str, Any]) -> dict[str, Any]: + out = dict(server) + for field, client_field in client.items(): + server_field = out.get(field) + if isinstance(server_field, dict) and isinstance(client_field, dict): + out[field] = _merge_server_wins(server_field, client_field) + elif field not in out: + out[field] = client_field + return out + + +def create_upto_payment_payload( + account: Any, + option: UptoOption, + *, + resource_url: str, + resource_description: str, + extensions: dict[str, Any] | None = None, + permit_nonce: int | None = None, + now: int | None = None, + nonce: int | None = None, +) -> str: + """Sign an upto payment and return the base64 ``PAYMENT-SIGNATURE`` value. + + ``permit_nonce`` set → also sign the EIP-2612 permit (USDC → Permit2 for the + ceiling) and attach it as ``extensions.eip2612GasSponsoring.info``. + ``now`` / ``nonce`` exist for deterministic tests. + """ + now = int(time.time()) if now is None else now + deadline = now + option.max_timeout_seconds + permit2_nonce = int.from_bytes(secrets.token_bytes(32), "big") if nonce is None else nonce + owner = to_checksum_address(account.address) + + signature = _sign( + account, + permit2_witness_typed_data( + chain_id=option.chain_id, + token=option.usdc, + amount=option.amount, + nonce=permit2_nonce, + deadline=deadline, + pay_to=option.pay_to, + facilitator=option.facilitator, + ), + ) + + merged = with_builder_code_service_code(extensions) + if permit_nonce is not None: + info = { + "from": owner, + "asset": option.usdc, + "spender": PERMIT2_ADDRESS, + # Must equal permitted.amount: the proxy reverts otherwise. + "amount": str(option.amount), + "nonce": str(permit_nonce), + "deadline": str(deadline), + "signature": _sign( + account, + eip2612_permit_typed_data( + domain=option.domain, + owner=owner, + value=option.amount, + nonce=permit_nonce, + deadline=deadline, + ), + ), + "version": EIP2612_GAS_SPONSORING_VERSION, + } + merged = merge_extensions(merged, {EIP2612_GAS_SPONSORING_KEY: {"info": info}}) + + payment = { + "x402Version": 2, + "resource": { + "url": resource_url, + "description": resource_description, + "mimeType": "application/json", + }, + # The requirement exactly as offered: a server may match `accepted` + # against its own accepts entry field for field. + "accepted": dict(option.requirement), + "payload": { + "signature": signature, + "permit2Authorization": { + "from": owner, + "permitted": {"token": option.usdc, "amount": str(option.amount)}, + "spender": X402_UPTO_PERMIT2_PROXY_ADDRESS, + "nonce": str(permit2_nonce), + "deadline": str(deadline), + "witness": { + "to": option.pay_to, + "facilitator": option.facilitator, + "validAfter": "0", + }, + }, + }, + "extensions": merged, + } + return base64.b64encode(json.dumps(payment).encode()).decode() + + +# --------------------------------------------------------------------------- +# One call: decide, then sign — or return None for exact +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class UptoPayment: + header: str # base64 PAYMENT-SIGNATURE + amount: int # signed ceiling, micro-USDC + permit_attached: bool + + @property + def ceiling_usd(self) -> float: + return self.amount / 1e6 + + +def _sign_planned( + account: Any, + option: UptoOption, + state: UptoChainState, + *, + resource_url: str, + resource_description: str, + extensions: dict[str, Any] | None, + may_permit: bool = True, +) -> UptoPayment | None: + needs_permit = plan_upto(option, state) + if needs_permit is None: + return None + if needs_permit and not may_permit: + logger.debug("x402 upto: another call holds the permit preflight; using exact") + return None + if not needs_permit: + # Permit2 can already pull the ceiling: no permit, whatever is in flight. + header = create_upto_payment_payload( + account, + option, + resource_url=resource_url, + resource_description=resource_description, + extensions=extensions, + ) + return UptoPayment(header, option.amount, permit_attached=False) + + assert state.permit_nonce is not None # plan_upto returns True only with one + now = int(_now()) + if not _claim_permit_slot( + option.network, account.address, state.permit_nonce, now + option.max_timeout_seconds + ): + logger.debug("x402 upto: a sponsored permit is still in flight; using exact") + return None + try: + header = create_upto_payment_payload( + account, + option, + resource_url=resource_url, + resource_description=resource_description, + extensions=extensions, + permit_nonce=state.permit_nonce, + now=now, + ) + except Exception: + _release_permit_slot(option.network, account.address, state.permit_nonce) + raise + return UptoPayment(header, option.amount, permit_attached=True) + + +def try_upto_payment( + account: Any, + option: UptoOption, + payment_required: dict[str, Any], + *, + resource_url: str, + resource_description: str, + within_limits: Callable[[float], bool], + read_state: Callable[[UptoOption, str], UptoChainState] | None = None, +) -> UptoPayment | None: + """Sign upto if the policy allows it; ``None`` (never an exception) means use exact.""" + try: + if not within_limits(option.ceiling_usd): + logger.debug("x402 upto: ceiling $%.6f breaches a spend limit", option.ceiling_usd) + return None + reader = read_state or read_upto_chain_state + # Claim the permit preflight BEFORE reading, whenever a permit might + # be signed (see _PERMIT_PREFLIGHT). + holds = option.gas_sponsoring and _reserve_preflight(option.network, account.address) + try: + state = reader(option, account.address) + return _sign_planned( + account, + option, + state, + resource_url=resource_url, + resource_description=resource_description, + extensions=payment_required.get("extensions"), + may_permit=holds, + ) + finally: + if holds: + _release_preflight(option.network, account.address) + except Exception as exc: + logger.debug("x402 upto: falling back to exact (%s: %s)", type(exc).__name__, exc) + return None + + +async def atry_upto_payment( + account: Any, + option: UptoOption, + payment_required: dict[str, Any], + *, + resource_url: str, + resource_description: str, + within_limits: Callable[[float], bool], + read_state: Callable[[UptoOption, str], Awaitable[UptoChainState]] | None = None, +) -> UptoPayment | None: + """Async :func:`try_upto_payment`.""" + try: + if not within_limits(option.ceiling_usd): + logger.debug("x402 upto: ceiling $%.6f breaches a spend limit", option.ceiling_usd) + return None + reader = read_state or aread_upto_chain_state + # Claim the permit preflight BEFORE reading, whenever a permit might + # be signed (see _PERMIT_PREFLIGHT). + holds = option.gas_sponsoring and _reserve_preflight(option.network, account.address) + try: + state = await reader(option, account.address) + return _sign_planned( + account, + option, + state, + resource_url=resource_url, + resource_description=resource_description, + extensions=payment_required.get("extensions"), + may_permit=holds, + ) + finally: + if holds: + _release_preflight(option.network, account.address) + except Exception as exc: + logger.debug("x402 upto: falling back to exact (%s: %s)", type(exc).__name__, exc) + return None + + +def settled_upto_amount(settlement: dict[str, Any] | None, ceiling: int) -> int | None: + """The settled micro-USDC a ``PAYMENT-RESPONSE`` reports, if it reports one + that is plausible for this ceiling (0 ≤ amount ≤ ceiling); else ``None``.""" + if not settlement: + return None + raw = settlement.get("amount_micro_usdc") + if raw is None or not _UINT_RE.match(str(raw)): + return None + amount = int(str(raw)) + return amount if amount <= ceiling else None + + +__all__ = [ + "EIP2612_GAS_SPONSORING_KEY", + "PAYMENT_SCHEMES", + "PERMIT2_ADDRESS", + "X402_UPTO_PERMIT2_PROXY_ADDRESS", + "UptoChainState", + "UptoOption", + "UptoPayment", + "UptoUnavailable", + "aread_upto_chain_state", + "atry_upto_payment", + "create_upto_payment_payload", + "eip2612_permit_typed_data", + "find_upto_option", + "gas_sponsoring_declared", + "merge_extensions", + "offers_upto", + "permit2_witness_typed_data", + "plan_upto", + "read_upto_chain_state", + "resolve_payment_scheme", + "settled_upto_amount", + "try_upto_payment", +] diff --git a/integrations/llama-index-llms-blockrun/README.md b/integrations/llama-index-llms-blockrun/README.md index b14d90a..34f140d 100644 --- a/integrations/llama-index-llms-blockrun/README.md +++ b/integrations/llama-index-llms-blockrun/README.md @@ -58,6 +58,8 @@ response = llm.chat( ) print(response.message.content) print(response.additional_kwargs.get("cost_usd")) # USD charged, where the SDK reports it (Base) +# Under x402 upto, cost_usd may be the signed ceiling, not the charge: +print(response.additional_kwargs.get("cost_is_ceiling")) for chunk in llm.stream_complete("Write a haiku about USDC."): print(chunk.delta, end="", flush=True) diff --git a/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py b/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py index 36db0fe..e5c64fe 100644 --- a/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py +++ b/integrations/llama-index-llms-blockrun/llama_index/llms/blockrun/base.py @@ -2,8 +2,10 @@ BlockRun is an OpenAI-compatible gateway where every call pays for itself in USDC over x402. There is no API key: the gateway answers an unpaid request with -HTTP 402 and a price, the client signs a payment for exactly that amount with a -local wallet, and the request is retried with the signature attached. +HTTP 402 and a price, the client signs a payment with a local wallet, and the +request is retried with the signature attached. That payment is either the +quoted amount (x402 ``exact``) or, on Base when the gateway offers it, a +ceiling the gateway settles at the actual cost (x402 ``upto``). That signing step is why this is a package rather than an ``OpenAILike`` snippet. ``OpenAILike(api_base=..., api_key="fake")`` reaches the gateway and @@ -518,7 +520,10 @@ def _check_solana_params(params: Iterable[str]) -> None: def _response_kwargs( - usage: Any, cost_usd: float | None = None, citations: list[str] | None = None + usage: Any, + cost_usd: float | None = None, + citations: list[str] | None = None, + source: Any = None, ) -> dict[str, Any]: out: dict[str, Any] = {} if usage is not None: @@ -527,6 +532,13 @@ def _response_kwargs( out["total_tokens"] = usage.total_tokens if cost_usd is not None: out["cost_usd"] = cost_usd + # Under x402 upto, cost_usd can be the signed CEILING rather than the + # settled charge (always, for streams). Carry the SDK's label so a + # caller never reports an upper bound as money spent. + scheme = getattr(source, "payment_scheme", None) + if scheme is not None: + out["payment_scheme"] = scheme + out["cost_is_ceiling"] = bool(getattr(source, "cost_is_ceiling", False)) if citations: out["citations"] = citations return out @@ -551,7 +563,10 @@ def _to_chat_response(response: Any) -> ChatResponse: message=ChatMessage(role=MessageRole.ASSISTANT, blocks=blocks), raw=response, additional_kwargs=_response_kwargs( - response.usage, getattr(response, "cost_usd", None), response.citations + response.usage, + getattr(response, "cost_usd", None), + response.citations, + source=response, ), ) @@ -598,7 +613,7 @@ def step(self, chunk: Any) -> ChatResponse: # The Base SDK attaches the real x402 charge to every chunk; read it # with getattr because it is an extra attribute, not a declared field. additional = _response_kwargs( - chunk.usage, getattr(chunk, "cost_usd", None), chunk.citations + chunk.usage, getattr(chunk, "cost_usd", None), chunk.citations, source=chunk ) if reasoning_delta: additional["thinking_delta"] = reasoning_delta diff --git a/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py b/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py index 4c3b00d..9f29288 100644 --- a/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py +++ b/integrations/llama-index-llms-blockrun/tests/test_llms_blockrun.py @@ -395,6 +395,17 @@ def test_charge_attached_to_chunks_is_surfaced(self) -> None: llm, _ = make_llm(FakeClient(chunks=[paid])) (response,) = list(llm.stream_chat([ChatMessage(role="user", content="hi")])) assert response.additional_kwargs["cost_usd"] == 0.0021 + assert "cost_is_ceiling" not in response.additional_kwargs + + def test_upto_ceiling_is_labeled_not_reported_as_a_charge(self) -> None: + paid = chunk("hi") + paid.cost_usd = 0.05 # type: ignore[attr-defined] + paid.payment_scheme = "upto" # type: ignore[attr-defined] + paid.cost_is_ceiling = True # type: ignore[attr-defined] + llm, _ = make_llm(FakeClient(chunks=[paid])) + (response,) = list(llm.stream_chat([ChatMessage(role="user", content="hi")])) + assert response.additional_kwargs["payment_scheme"] == "upto" + assert response.additional_kwargs["cost_is_ceiling"] is True def test_stream_complete(self) -> None: llm, _ = make_llm(FakeClient(chunks=[chunk("a"), chunk("b")])) diff --git a/scripts/gen-upto-vector.mjs b/scripts/gen-upto-vector.mjs new file mode 100644 index 0000000..e734541 --- /dev/null +++ b/scripts/gen-upto-vector.mjs @@ -0,0 +1,144 @@ +// Generates tests/unit/x402_upto_reference_vector.json from the official +// @x402/evm 2.28.0 upto client (UptoEvmScheme + trySignEip2612PermitExtension), +// which tests/unit/test_x402_upto.py checks the Python signer against. +// Deterministic: Date.now and crypto.getRandomValues are pinned. +// +// Not run by CI. To regenerate: +// npm pack @x402/evm@2.28.0 && tar xzf x402-evm-2.28.0.tgz # → ./package +// (cd package && npm i viem@2 --no-save) +// cp scripts/gen-upto-vector.mjs package/ && node package/gen-upto-vector.mjs \ +// > tests/unit/x402_upto_reference_vector.json +// The chunk file names below are those of the 2.28.0 build; the package index +// is avoided because it pulls in @x402/core. +import { privateKeyToAccount } from "viem/accounts"; +import { hashTypedData } from "viem"; +import { UptoEvmScheme } from "./dist/esm/chunk-3LEU2NZ6.mjs"; +import { PERMIT2_ADDRESS, x402UptoPermit2ProxyAddress } from "./dist/esm/chunk-GPMYUGHX.mjs"; + +const NOW_MS = 1790000000000; +Date.now = () => NOW_MS; +Object.defineProperty(globalThis, "crypto", { + value: { getRandomValues: (arr) => { arr.fill(0x11); return arr; } }, + configurable: true, +}); + +const account = privateKeyToAccount( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", +); + +const requirements = { + scheme: "upto", + network: "eip155:8453", + amount: "123456", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + maxTimeoutSeconds: 300, + extra: { + name: "USD Coin", + version: "2", + facilitatorAddress: "0x97AcCe27D5069544480BDe0F04D9F47d7422a016", + }, +}; + +const reads = []; +const signer = { + address: account.address, + signTypedData: (msg) => account.signTypedData(msg), + readContract: async (args) => { + reads.push(args.functionName); + if (args.functionName === "allowance") return 0n; + if (args.functionName === "nonces") return 7n; + throw new Error("unexpected read " + args.functionName); + }, +}; + +const scheme = new UptoEvmScheme(signer); + +// 1) No gas sponsoring declared → bare Permit2 payload. +const bare = await scheme.createPaymentPayload(2, requirements, { extensions: {} }); + +// 2) Gas sponsoring declared, allowance 0 → EIP-2612 permit attached. Its +// value is the per-call ceiling (permitted.amount): the upto proxy reverts +// with Permit2612AmountMismatch on anything else. +const sponsored = await scheme.createPaymentPayload(2, requirements, { + extensions: { eip2612GasSponsoring: { info: { version: "1" } } }, +}); + +const a = bare.payload.permit2Authorization; +const permit2Digest = hashTypedData({ + domain: { name: "Permit2", chainId: 8453, verifyingContract: PERMIT2_ADDRESS }, + types: { + PermitWitnessTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "witness", type: "Witness" }, + ], + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], + Witness: [ + { name: "to", type: "address" }, + { name: "facilitator", type: "address" }, + { name: "validAfter", type: "uint256" }, + ], + }, + primaryType: "PermitWitnessTransferFrom", + message: { + permitted: { token: a.permitted.token, amount: BigInt(a.permitted.amount) }, + spender: a.spender, + nonce: BigInt(a.nonce), + deadline: BigInt(a.deadline), + witness: { + to: a.witness.to, + facilitator: a.witness.facilitator, + validAfter: BigInt(a.witness.validAfter), + }, + }, +}); + +const eip2612DigestOf = (info) => hashTypedData({ + domain: { + name: "USD Coin", + version: "2", + chainId: 8453, + verifyingContract: requirements.asset, + }, + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "Permit", + message: { + owner: info.from, + spender: info.spender, + value: BigInt(info.amount), + nonce: BigInt(info.nonce), + deadline: BigInt(info.deadline), + }, +}); +const eip2612Digest = eip2612DigestOf(sponsored.extensions.eip2612GasSponsoring.info); + +console.log( + JSON.stringify( + { + source: "@x402/evm 2.28.0 UptoEvmScheme (viem " + (await import("viem/package.json", { with: { type: "json" } })).default.version + ")", + inputs: { privateKey: "hardhat #0", nowSeconds: NOW_MS / 1000, randomBytes: "0x11 * 32", requirements, usdcNonce: "7", allowance: "0" }, + constants: { PERMIT2_ADDRESS, x402UptoPermit2ProxyAddress }, + bare, + sponsored, + permit2Digest, + eip2612Digest, + reads, + }, + null, + 2, + ), +); diff --git a/tests/unit/test_x402_upto.py b/tests/unit/test_x402_upto.py new file mode 100644 index 0000000..39f9adb --- /dev/null +++ b/tests/unit/test_x402_upto.py @@ -0,0 +1,1506 @@ +"""x402 ``upto`` (Permit2) payments. + +What these tests pin: + +1. **Wire compatibility** — for fixed inputs the Python signer produces the + byte-identical Permit2 witness signature, EIP-2612 permit signature and + payload fields as the official ``@x402/evm`` 2.28.0 client. The vector in + ``x402_upto_reference_vector.json`` was generated by that client + (``scripts/gen-upto-vector.mjs``), not by this code. +2. **Selection policy** — upto is chosen only when the 402 offers it with a + facilitator, the wallet holds the ceiling, and Permit2 may already pull it + or the gateway sponsors the approval. Every other case — and every error — + signs ``exact`` exactly as before. +3. **Rejection** — an upto payment the gateway refuses before serving anything + is retried once with exact; a 2xx is never retried. +4. **Accounting** — the upto ceiling is never booked as a confirmed charge. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest +from eth_account import Account +from eth_account.messages import _hash_eip191_message, encode_typed_data + +from blockrun_llm import AsyncLLMClient, LLMClient, cache +from blockrun_llm import x402_upto as upto +from blockrun_llm.tx_log import format_row +from blockrun_llm.types import PaymentError +from blockrun_llm.x402_upto import ( + PERMIT2_ADDRESS, + X402_UPTO_PERMIT2_PROXY_ADDRESS, + UptoChainState, + create_upto_payment_payload, + eip2612_permit_typed_data, + find_upto_option, + merge_extensions, + permit2_witness_typed_data, + plan_upto, + resolve_payment_scheme, +) + +from ..helpers import TEST_ACCOUNT, TEST_PRIVATE_KEY, TEST_RECIPIENT, build_chat_response + +# The real chain readers, captured before the autouse fixture replaces them. +REAL_READ = upto.read_upto_chain_state +REAL_AREAD = upto.aread_upto_chain_state + +VECTOR = json.loads((Path(__file__).parent / "x402_upto_reference_vector.json").read_text()) +REQ = VECTOR["inputs"]["requirements"] +NOW = int(VECTOR["inputs"]["nowSeconds"]) +NONCE_0x11 = int.from_bytes(b"\x11" * 32, "big") + +USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +FACILITATOR = "0x97AcCe27D5069544480BDe0F04D9F47d7422a016" +MESSAGES = [{"role": "user", "content": "hi"}] + +EXACT_AMOUNT = "1000" # $0.001 — what exact signs +UPTO_CEILING = "50000" # $0.05 — the upto ceiling (full max_tokens) + +SPONSORING_DECL = { + "info": { + "description": "The facilitator accepts EIP-2612 gasless Permit to `Permit2` canonical contract.", + "version": "1", + }, + "schema": {"type": "object"}, +} + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + + +def exact_entry(amount: str = EXACT_AMOUNT) -> dict[str, Any]: + return { + "scheme": "exact", + "network": "eip155:8453", + "amount": amount, + "asset": USDC_BASE, + "payTo": TEST_RECIPIENT, + "maxTimeoutSeconds": 300, + "extra": {"name": "USD Coin", "version": "2"}, + } + + +def upto_entry(amount: str = UPTO_CEILING, **overrides: Any) -> dict[str, Any]: + entry: dict[str, Any] = { + "scheme": "upto", + "network": "eip155:8453", + "amount": amount, + "asset": USDC_BASE, + "payTo": TEST_RECIPIENT, + "maxTimeoutSeconds": 300, + "extra": {"name": "USD Coin", "version": "2", "facilitatorAddress": FACILITATOR}, + } + entry.update(overrides) + return entry + + +def payment_required( + *, with_upto: bool = True, sponsoring: bool = False, upto: dict | None = None +) -> dict[str, Any]: + accepts = [exact_entry()] + if with_upto: + accepts.append(upto or upto_entry()) + extensions: dict[str, Any] = {"builder-code": {"info": {"a": ["app"]}}} + if sponsoring: + extensions["eip2612GasSponsoring"] = SPONSORING_DECL + return { + "x402Version": 2, + "accepts": accepts, + "resource": { + "url": "https://blockrun.ai/api/v1/chat/completions", + "description": "AI model inference", + }, + "extensions": extensions, + } + + +def b64(obj: dict) -> str: + return base64.b64encode(json.dumps(obj).encode()).decode() + + +def decode(header: str) -> dict[str, Any]: + return json.loads(base64.b64decode(header)) + + +def settlement_header(amount: str | None = None) -> str: + data: dict[str, Any] = { + "success": True, + "transaction": "0x" + "ab" * 32, + "network": "eip155:8453", + } + if amount is not None: + data["amount"] = amount + return b64(data) + + +class Gateway: + """A MockTransport gateway: 402 on the unpaid call, then scripted answers + for each paid call. Records every PAYMENT-SIGNATURE it receives.""" + + def __init__( + self, + pr: dict[str, Any], + paid: list[httpx.Response] | None = None, + *, + price_usd: str | None = None, + header: bool = True, + ): + self.pr = pr + self.paid = list(paid or []) + self.signed: list[dict[str, Any]] = [] + self.price_usd = price_usd + self.header = header + + def __call__(self, request: httpx.Request) -> httpx.Response: + sig = request.headers.get("PAYMENT-SIGNATURE") + if sig is None: + if self.header: + return httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": b64(self.pr)}, + ) + body: dict[str, Any] = {**self.pr, "x402": True} + if self.price_usd is not None: + body["price"] = {"amount": self.price_usd, "currency": "USD"} + return httpx.Response(402, json=body) + self.signed.append(decode(sig)) + if self.paid: + return self.paid.pop(0) + return httpx.Response( + 200, json=build_chat_response(), headers={"PAYMENT-RESPONSE": settlement_header()} + ) + + @property + def schemes(self) -> list[str]: + return [s["accepted"]["scheme"] for s in self.signed] + + +def ok(amount: str | None = None) -> httpx.Response: + return httpx.Response( + 200, json=build_chat_response(), headers={"PAYMENT-RESPONSE": settlement_header(amount)} + ) + + +def verify_failed() -> httpx.Response: + return httpx.Response( + 402, json={"error": "Payment verification failed", "debug": "permit2_allowance_required"} + ) + + +def sync_client(gw: Gateway, **kwargs: Any) -> LLMClient: + client = LLMClient(private_key=TEST_PRIVATE_KEY, **kwargs) + client._client = httpx.Client(transport=httpx.MockTransport(gw)) + return client + + +def async_client(gw: Gateway, **kwargs: Any) -> AsyncLLMClient: + client = AsyncLLMClient(private_key=TEST_PRIVATE_KEY, **kwargs) + client._client = httpx.AsyncClient(transport=httpx.MockTransport(gw)) + return client + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Keep the local archive out of ~/.blockrun, clear the permit registry, + and make any unmocked chain read fail loudly rather than hit the network.""" + monkeypatch.setattr(cache, "CACHE_DIR", tmp_path / "cache") + monkeypatch.setattr(cache, "DATA_DIR", tmp_path / "data") + monkeypatch.setattr(cache, "COST_LOG_PATH", tmp_path / "cost_log.jsonl") + monkeypatch.delenv(upto.PAYMENT_SCHEME_ENV, raising=False) + upto._PERMITS_IN_FLIGHT.clear() + upto._PERMIT_PREFLIGHT.clear() + + def no_network(*_a: Any, **_kw: Any) -> Any: + raise AssertionError("unmocked chain read") + + monkeypatch.setattr(upto, "read_upto_chain_state", no_network) + monkeypatch.setattr(upto, "aread_upto_chain_state", no_network) + yield + upto._PERMITS_IN_FLIGHT.clear() + upto._PERMIT_PREFLIGHT.clear() + + +class Chain: + """Scripted chain state for the upto reads (sync and async).""" + + def __init__( + self, + *, + balance: int = 10**9, + allowance: int = 10**9, + nonce: int = 7, + fail: bool = False, + ): + self.balance, self.allowance, self.nonce, self.fail = balance, allowance, nonce, fail + self.reads = 0 + + def state(self, option: upto.UptoOption, owner: str) -> UptoChainState: + self.reads += 1 + if self.fail: + raise upto.UptoUnavailable("every RPC failed") + assert owner == TEST_ACCOUNT.address + need = option.gas_sponsoring and self.allowance < option.amount + return UptoChainState(self.balance, self.allowance, self.nonce if need else None) + + def install(self, monkeypatch: pytest.MonkeyPatch) -> Chain: + monkeypatch.setattr(upto, "read_upto_chain_state", self.state) + + async def astate(option: upto.UptoOption, owner: str) -> UptoChainState: + return self.state(option, owner) + + monkeypatch.setattr(upto, "aread_upto_chain_state", astate) + return self + + +# --------------------------------------------------------------------------- +# 1. Wire compatibility with the official client +# --------------------------------------------------------------------------- + + +class TestReferenceVector: + def _option(self, sponsoring: bool): + pr = {"x402Version": 2, "accepts": [REQ]} + if sponsoring: + pr["extensions"] = {"eip2612GasSponsoring": {"info": {"version": "1"}}} + option = find_upto_option(pr) + assert option is not None + return option, pr + + def test_constants_match_reference(self): + assert PERMIT2_ADDRESS == VECTOR["constants"]["PERMIT2_ADDRESS"] + assert X402_UPTO_PERMIT2_PROXY_ADDRESS == VECTOR["constants"]["x402UptoPermit2ProxyAddress"] + + def test_permit2_typed_data_hash_matches_viem(self): + option, _ = self._option(False) + typed = permit2_witness_typed_data( + chain_id=8453, + token=option.usdc, + amount=option.amount, + nonce=NONCE_0x11, + deadline=NOW + 300, + pay_to=option.pay_to, + facilitator=option.facilitator, + ) + digest = _hash_eip191_message(encode_typed_data(full_message=typed)) + assert "0x" + digest.hex() == VECTOR["permit2Digest"] + + def test_eip2612_typed_data_hash_matches_viem(self): + option, _ = self._option(True) + typed = eip2612_permit_typed_data( + domain=option.domain, + owner=TEST_ACCOUNT.address, + value=option.amount, + nonce=7, + deadline=NOW + 300, + ) + digest = _hash_eip191_message(encode_typed_data(full_message=typed)) + assert "0x" + digest.hex() == VECTOR["eip2612Digest"] + + def test_bare_payload_is_byte_identical(self): + option, pr = self._option(False) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="https://blockrun.ai/api/v1/chat/completions", + resource_description="d", + extensions=pr.get("extensions"), + now=NOW, + nonce=NONCE_0x11, + ) + ) + assert payment["payload"] == VECTOR["bare"]["payload"] + assert "eip2612GasSponsoring" not in payment["extensions"] + + def test_sponsored_payload_is_byte_identical(self): + option, pr = self._option(True) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="https://blockrun.ai/api/v1/chat/completions", + resource_description="d", + extensions=pr["extensions"], + permit_nonce=7, + now=NOW, + nonce=NONCE_0x11, + ) + ) + expected = VECTOR["sponsored"] + assert payment["payload"] == expected["payload"] + info = payment["extensions"]["eip2612GasSponsoring"]["info"] + assert info == expected["extensions"]["eip2612GasSponsoring"]["info"] + + def test_permit_value_equals_permitted_amount(self): + """The upto proxy reverts (Permit2612AmountMismatch) unless the EIP-2612 + permit's value is exactly the Permit2 permitted amount — so MaxUint256 + (or anything but the ceiling) is a signed payment that cannot settle.""" + option, pr = self._option(True) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="u", + resource_description="d", + extensions=pr["extensions"], + permit_nonce=7, + ) + ) + info = payment["extensions"]["eip2612GasSponsoring"]["info"] + permitted = payment["payload"]["permit2Authorization"]["permitted"]["amount"] + assert info["amount"] == permitted == REQ["amount"] + # …and the signature is over that value, not just the JSON field. + typed = eip2612_permit_typed_data( + domain=option.domain, + owner=TEST_ACCOUNT.address, + value=int(permitted), + nonce=7, + deadline=int(info["deadline"]), + ) + signer = Account.recover_message( + encode_typed_data(full_message=typed), signature=info["signature"] + ) + assert signer == TEST_ACCOUNT.address + + def test_signatures_recover_to_the_payer(self): + auth = VECTOR["sponsored"]["payload"]["permit2Authorization"] + typed = permit2_witness_typed_data( + chain_id=8453, + token=auth["permitted"]["token"], + amount=int(auth["permitted"]["amount"]), + nonce=int(auth["nonce"]), + deadline=int(auth["deadline"]), + pay_to=auth["witness"]["to"], + facilitator=auth["witness"]["facilitator"], + ) + signer = Account.recover_message( + encode_typed_data(full_message=typed), + signature=VECTOR["sponsored"]["payload"]["signature"], + ) + assert signer == TEST_ACCOUNT.address + + +class TestPayloadEnvelope: + def test_envelope_matches_exact_and_echoes_the_requirement(self): + pr = payment_required(sponsoring=True) + option = find_upto_option(pr) + payment = decode( + create_upto_payment_payload( + TEST_ACCOUNT, + option, + resource_url="https://blockrun.ai/api/v1/chat/completions", + resource_description="AI model inference", + extensions=pr["extensions"], + permit_nonce=3, + ) + ) + assert payment["x402Version"] == 2 + assert payment["accepted"] == upto_entry() # verbatim, facilitatorAddress included + assert payment["resource"]["url"] == "https://blockrun.ai/api/v1/chat/completions" + # BlockRun's builder service code merged in, the server's app code kept. + assert payment["extensions"]["builder-code"]["info"] == {"a": ["app"], "s": ["blockrun"]} + # @x402/core merge semantics: server-declared fields stay, client fields added. + info = payment["extensions"]["eip2612GasSponsoring"]["info"] + assert info["description"] == SPONSORING_DECL["info"]["description"] + assert info["version"] == "1" + assert info["spender"] == PERMIT2_ADDRESS + assert info["amount"] == UPTO_CEILING # must equal permitted.amount + assert info["nonce"] == "3" + assert info["deadline"] == payment["payload"]["permit2Authorization"]["deadline"] + assert payment["extensions"]["eip2612GasSponsoring"]["schema"] == {"type": "object"} + + def test_nonce_is_random_256_bit_decimal(self): + option = find_upto_option(payment_required()) + nonces = { + decode( + create_upto_payment_payload( + TEST_ACCOUNT, option, resource_url="u", resource_description="d" + ) + )["payload"]["permit2Authorization"]["nonce"] + for _ in range(3) + } + assert len(nonces) == 3 + assert all(n.isdigit() and int(n) < 2**256 for n in nonces) + + def test_merge_extensions_non_object_replaces(self): + assert merge_extensions( + {"a": 1, "b": {"x": 1}}, {"a": {"y": 2}, "b": {"x": 9, "z": 3}} + ) == { + "a": {"y": 2}, + "b": {"x": 1, "z": 3}, + } + + +# --------------------------------------------------------------------------- +# 2. Selection policy +# --------------------------------------------------------------------------- + + +class TestFindOption: + def test_valid_offer(self): + option = find_upto_option(payment_required(sponsoring=True)) + assert option is not None + assert option.amount == int(UPTO_CEILING) + assert option.facilitator == FACILITATOR + assert option.gas_sponsoring is True + assert option.chain_id == 8453 + + @pytest.mark.parametrize( + "bad", + [ + upto_entry(extra={"name": "USD Coin", "version": "2"}), # no facilitatorAddress + upto_entry(asset="0x0000000000000000000000000000000000000001"), # not USDC + upto_entry(network="eip155:1"), # a network this SDK does not sign on + upto_entry(network="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), # Solana: exact only + upto_entry(amount="0"), + upto_entry(amount="-5"), + upto_entry(payTo="nope"), + upto_entry(maxTimeoutSeconds=0), + ], + ) + def test_unusable_offer_is_ignored(self, bad): + assert find_upto_option(payment_required(upto=bad)) is None + + def test_no_upto_offer(self): + assert find_upto_option(payment_required(with_upto=False)) is None + + def test_v1_max_amount_required(self): + entry = upto_entry() + entry["maxAmountRequired"] = entry.pop("amount") + option = find_upto_option(payment_required(upto=entry)) + assert option is not None and option.amount == int(UPTO_CEILING) + + +class TestPlan: + option = find_upto_option(payment_required()) + sponsored = find_upto_option(payment_required(sponsoring=True)) + ceiling = int(UPTO_CEILING) + + def test_allowance_covers_ceiling(self): + assert plan_upto(self.option, UptoChainState(self.ceiling, self.ceiling, None)) is False + + def test_no_allowance_no_sponsoring_is_exact(self): + assert plan_upto(self.option, UptoChainState(10**9, self.ceiling - 1, None)) is None + + def test_no_allowance_with_sponsoring_needs_permit(self): + assert plan_upto(self.sponsored, UptoChainState(10**9, 0, 4)) is True + + def test_balance_below_ceiling_is_exact(self): + """Exact might fit a wallet the ceiling does not: never worse than today.""" + assert plan_upto(self.sponsored, UptoChainState(self.ceiling - 1, 10**9, None)) is None + + +class TestResolveScheme: + def test_default_auto(self): + assert resolve_payment_scheme(None) == "auto" + + def test_env(self, monkeypatch): + monkeypatch.setenv(upto.PAYMENT_SCHEME_ENV, "EXACT") + assert resolve_payment_scheme(None) == "exact" + + def test_argument_beats_env(self, monkeypatch): + monkeypatch.setenv(upto.PAYMENT_SCHEME_ENV, "exact") + assert resolve_payment_scheme("auto") == "auto" + + def test_invalid(self): + with pytest.raises(ValueError, match="payment_scheme"): + resolve_payment_scheme("upto") + + def test_client_rejects_invalid(self): + with pytest.raises(ValueError): + LLMClient(private_key=TEST_PRIVATE_KEY, payment_scheme="cheapest") + + +class TestClientSelection: + def test_prefers_upto_with_allowance(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required()) + response = sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + sent = gw.signed[0] + auth = sent["payload"]["permit2Authorization"] + assert auth["permitted"] == {"token": USDC_BASE, "amount": UPTO_CEILING} + assert auth["spender"] == X402_UPTO_PERMIT2_PROXY_ADDRESS + assert auth["witness"]["facilitator"] == FACILITATOR + assert "eip2612GasSponsoring" not in sent["extensions"] + assert response.payment_scheme == "upto" + assert chain.reads == 1 + + def test_sponsored_permit_attached_when_no_allowance(self, monkeypatch): + Chain(allowance=0, nonce=11).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + info = gw.signed[0]["extensions"]["eip2612GasSponsoring"]["info"] + assert info["nonce"] == "11" and info["amount"] == UPTO_CEILING + # The permit's value must equal Permit2's permitted amount. + assert ( + gw.signed[0]["payload"]["permit2Authorization"]["permitted"]["amount"] == UPTO_CEILING + ) + assert info["from"] == TEST_ACCOUNT.address + + def test_sponsoring_declared_but_allowance_suffices_skips_permit(self, monkeypatch): + Chain(allowance=10**9).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + # The server's declaration is echoed (as @x402/core does) but carries no signed permit. + assert "signature" not in gw.signed[0]["extensions"]["eip2612GasSponsoring"]["info"] + + def test_no_allowance_no_sponsoring_falls_back(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=False)) + response = sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert gw.signed[0]["payload"]["authorization"]["value"] == EXACT_AMOUNT + assert response.payment_scheme == "exact" + assert response.cost_usd == pytest.approx(0.001) + + def test_rpc_failure_falls_back(self, monkeypatch): + Chain(fail=True).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_signing_error_falls_back(self, monkeypatch): + Chain().install(monkeypatch) + + def boom(*_a, **_kw): + raise RuntimeError("signer exploded") + + monkeypatch.setattr(upto, "create_upto_payment_payload", boom) + gw = Gateway(payment_required()) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_missing_facilitator_never_reads_chain(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(upto=upto_entry(extra={"name": "USD Coin", "version": "2"}))) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + + def test_opt_out_argument(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw, payment_scheme="exact").chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + + def test_opt_out_env(self, monkeypatch): + monkeypatch.setenv(upto.PAYMENT_SCHEME_ENV, "exact") + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + + def test_insufficient_balance_falls_back(self, monkeypatch): + Chain(balance=int(UPTO_CEILING) - 1).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_ceiling_over_per_call_limit_signs_exact_instead_of_refusing(self, monkeypatch): + """The cap is checked against the ceiling; a ceiling over the cap is not a + refusal when exact fits under it.""" + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required()) + response = sync_client(gw, max_cost_per_call=0.01).chat_completion( + "deepseek/deepseek-chat", MESSAGES + ) + assert gw.schemes == ["exact"] + assert response.cost_usd == pytest.approx(0.001) + assert chain.reads == 0 # refused on the ceiling before any chain read + + def test_exact_fallback_books_its_own_amount_not_the_ceiling_price(self, monkeypatch): + """A gateway offering upto quotes `price` at the ceiling; exact must not + book (or cap on) that number.""" + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(), header=False, price_usd="0.050000") + client = sync_client(gw, max_cost_per_call=0.01) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert response.cost_usd == pytest.approx(0.001) + assert client.get_spending()["total_usd"] == pytest.approx(0.001) + + def test_exact_only_402_is_unchanged(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(with_upto=False)) + response = sync_client(gw).chat_completion("openai/gpt-5.2", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 0 + assert response.payment_scheme == "exact" + assert response.cost_is_ceiling is False + + def test_permit_in_flight_pays_exact_until_nonce_is_consumed(self, monkeypatch): + """Per wallet only one gas-sponsored upto payment is in flight: while the + on-chain USDC nonce still equals the one signed over (and the permit has + not expired), a call that would need another permit pays exact. Once the + nonce moves, the next permit is signed over the new one.""" + chain = Chain(allowance=0, nonce=5).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # permit over nonce 5 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # nonce still 5 → exact + chain.nonce = 6 # the first permit settled (and its allowance was spent) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "upto"] + nonces = [ + s["extensions"]["eip2612GasSponsoring"]["info"]["nonce"] + for s in gw.signed + if s["accepted"]["scheme"] == "upto" + ] + assert nonces == ["5", "6"] + + def test_permit_in_flight_cleared_after_deadline(self, monkeypatch): + clock = [1_000_000.0] + monkeypatch.setattr(upto, "_now", lambda: clock[0]) + Chain(allowance=0, nonce=5).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) # maxTimeoutSeconds 300 + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # permit, deadline +300 + clock[0] += 299 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # in flight → exact + clock[0] += 1 # at the deadline the permit can no longer land + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "upto"] + assert gw.signed[2]["extensions"]["eip2612GasSponsoring"]["info"]["nonce"] == "5" + + def test_allowance_covering_ceiling_needs_no_permit_even_in_flight(self, monkeypatch): + chain = Chain(allowance=0, nonce=5).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # permit over nonce 5 + chain.allowance = int(UPTO_CEILING) # e.g. an approval made elsewhere + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "upto"] + assert "signature" not in gw.signed[1]["extensions"]["eip2612GasSponsoring"]["info"] + + def test_permit_guard_is_per_wallet_and_network(self): + far = int(upto._now()) + 300 + assert upto._claim_permit_slot("eip155:8453", "0xAAA", 5, far) + assert not upto._claim_permit_slot("eip155:8453", "0xaaa", 5, far) + assert upto._claim_permit_slot("eip155:8453", "0xBBB", 5, far) + assert upto._claim_permit_slot("eip155:84532", "0xAAA", 5, far) + assert upto._claim_permit_slot("eip155:8453", "0xAAA", 6, far) # nonce moved + + def test_signing_failure_releases_the_slot(self, monkeypatch): + Chain(allowance=0, nonce=5).install(monkeypatch) + real = upto.create_upto_payment_payload + calls = [0] + + def flaky(*a, **kw): + calls[0] += 1 + if calls[0] == 1: + raise RuntimeError("signer hiccup") + return real(*a, **kw) + + monkeypatch.setattr(upto, "create_upto_payment_payload", flaky) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # error → exact + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # slot free → permit + assert gw.schemes == ["exact", "upto"] + + +class TestConcurrentPermits: + """Three concurrent calls in ONE client, chain at allowance 0 / nonce 6: + exactly one signs a permit, the other two pay exact.""" + + @staticmethod + def _permits(gw: Gateway) -> list[str]: + return [ + s["extensions"]["eip2612GasSponsoring"]["info"]["nonce"] + for s in gw.signed + if s["accepted"]["scheme"] == "upto" + and "signature" in s["extensions"]["eip2612GasSponsoring"]["info"] + ] + + def test_threads(self, monkeypatch): + import threading + import time as _time + from concurrent.futures import ThreadPoolExecutor + + chain = Chain(allowance=0, nonce=6) + lock = threading.Lock() + + def slow_state(option, owner): + _time.sleep(0.1) # every call is inside its read at once + with lock: + return chain.state(option, owner) + + monkeypatch.setattr(upto, "read_upto_chain_state", slow_state) + gw = Gateway(payment_required(sponsoring=True)) + client = sync_client(gw) + with ThreadPoolExecutor(max_workers=3) as pool: + list( + pool.map( + lambda _: client.chat_completion("deepseek/deepseek-chat", MESSAGES), range(3) + ) + ) + assert self._permits(gw) == ["6"] + assert sorted(gw.schemes) == ["exact", "exact", "upto"] + assert upto._PERMIT_PREFLIGHT == set() + + def test_asyncio_gather(self, monkeypatch): + chain = Chain(allowance=0, nonce=6) + + async def slow_astate(option, owner): + await asyncio.sleep(0.05) + return chain.state(option, owner) + + monkeypatch.setattr(upto, "aread_upto_chain_state", slow_astate) + gw = Gateway(payment_required(sponsoring=True)) + + async def run(): + client = async_client(gw) + await asyncio.gather( + *(client.chat_completion("deepseek/deepseek-chat", MESSAGES) for _ in range(3)) + ) + + asyncio.run(run()) + assert self._permits(gw) == ["6"] + assert sorted(gw.schemes) == ["exact", "exact", "upto"] + assert upto._PERMIT_PREFLIGHT == set() + + def test_marker_holder_elsewhere_still_allows_upto_without_permit(self, monkeypatch): + Chain(allowance=10**9).install(monkeypatch) + assert upto._reserve_preflight("eip155:8453", TEST_ACCOUNT.address) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + assert "signature" not in gw.signed[0]["extensions"]["eip2612GasSponsoring"]["info"] + + def test_marker_holder_elsewhere_and_no_allowance_is_exact(self, monkeypatch): + chain = Chain(allowance=0, nonce=6).install(monkeypatch) + assert upto._reserve_preflight("eip155:8453", TEST_ACCOUNT.address) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + assert chain.reads == 1 + + def test_marker_released_when_no_permit_or_signing_fails(self, monkeypatch): + Chain(allowance=10**9).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True)) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert upto._PERMIT_PREFLIGHT == set() + + Chain(allowance=0, nonce=6).install(monkeypatch) + monkeypatch.setattr( + upto, + "create_upto_payment_payload", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError()), + ) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert upto._PERMIT_PREFLIGHT == set() + assert upto._PERMITS_IN_FLIGHT == {} + + +# --------------------------------------------------------------------------- +# 3. Rejection → one exact retry +# --------------------------------------------------------------------------- + + +class TestRejection: + def test_upto_rejected_then_exact_succeeds_and_is_remembered(self, monkeypatch): + chain = Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + assert response.payment_scheme == "exact" + assert response.cost_usd == pytest.approx(0.001) + assert client.get_spending() == { + "total_usd": pytest.approx(0.001), + "calls": 1, + "ceiling_usd": 0.0, + } + # Remembered (for UPTO_REJECTION_TTL_SECONDS): straight to exact, no chain read. + reads = chain.reads + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "exact"] + assert chain.reads == reads + + def test_rejection_is_per_client(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "upto"] + + def test_payment_code_4xx_body_counts_as_rejection(self, monkeypatch): + Chain().install(monkeypatch) + refused = httpx.Response(400, json={"error": "bad", "code": "PAYMENT_INVALID"}) + gw = Gateway(payment_required(), paid=[refused, ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + + def test_exact_retry_also_rejected_surfaces_original_error(self, monkeypatch): + Chain().install(monkeypatch) + original = httpx.Response(400, json={"error": "Payment verification failed", "x": "upto"}) + second = httpx.Response(402, json={"error": "Payment verification failed", "x": "exact"}) + gw = Gateway(payment_required(), paid=[original, second]) + client = sync_client(gw) + from blockrun_llm.types import APIError + + with pytest.raises(APIError) as exc: + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert exc.value.status_code == 400 # the upto refusal, not the exact one + assert gw.schemes == ["upto", "exact"] # no third attempt + assert client.get_spending()["calls"] == 0 + + def test_rejection_expires_after_the_ttl(self, monkeypatch): + import blockrun_llm.client as client_mod + + Chain().install(monkeypatch) + clock = {"t": 1000.0} + monkeypatch.setattr(client_mod.time, "monotonic", lambda: clock["t"]) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + client = sync_client(gw) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "exact"] + clock["t"] += client_mod.UPTO_REJECTION_TTL_SECONDS + 1 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact", "exact", "upto"] + + def test_rejection_after_a_5xx_replay_never_pays_exact(self, monkeypatch): + # The first send may have been served and settled behind the 502; the + # replay's "verification failed" is then the used Permit2 nonce. Paying + # exact on top would charge twice. + monkeypatch.setattr("time.sleep", lambda _s: None) + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(502), verify_failed(), ok()]) + client = sync_client(gw) + with pytest.raises(PaymentError): + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "upto"] + assert client._upto_rejected == {} + + def test_async_rejection_after_a_5xx_replay_never_pays_exact(self, monkeypatch): + async def no_sleep(_s): + return None + + monkeypatch.setattr("asyncio.sleep", no_sleep) + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(503), verify_failed(), ok()]) + + async def run(): + await async_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + + with pytest.raises(PaymentError): + asyncio.run(run()) + assert gw.schemes == ["upto", "upto"] + + def test_exact_payment_rejected_is_never_retried(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + with pytest.raises(PaymentError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["exact"] + + def test_2xx_never_triggers_a_retry(self, monkeypatch): + Chain().install(monkeypatch) + # A 200 whose body even mentions payment failure is served, not retried. + served = httpx.Response( + 200, + json={**build_chat_response(), "code": "PAYMENT_INVALID"}, + headers={"PAYMENT-RESPONSE": settlement_header()}, + ) + gw = Gateway(payment_required(), paid=[served]) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + assert response.payment_scheme == "upto" + assert client._upto_rejected == {} + + def test_non_payment_error_after_upto_is_not_retried(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(400, json={"error": "bad request"})]) + from blockrun_llm.types import APIError + + with pytest.raises(APIError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + def test_async_upto_rejected_then_exact(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), ok()]) + + async def run(): + client = async_client(gw) + first = await client.chat_completion("deepseek/deepseek-chat", MESSAGES) + await client.chat_completion("deepseek/deepseek-chat", MESSAGES) + return first + + first = asyncio.run(run()) + assert gw.schemes == ["upto", "exact", "exact"] + assert first.payment_scheme == "exact" + + def test_async_exact_retry_rejected_surfaces_original(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), verify_failed()]) + + async def run(): + await async_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + + with pytest.raises(PaymentError): + asyncio.run(run()) + assert gw.schemes == ["upto", "exact"] + + +# --------------------------------------------------------------------------- +# 4. Accounting: a ceiling is not a charge +# --------------------------------------------------------------------------- + + +class TestAccounting: + def test_ceiling_booked_and_labeled_without_settled_amount(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required()) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert response.cost_usd == pytest.approx(0.05) + assert response.cost_is_ceiling is True + assert client.get_spending() == { + "total_usd": pytest.approx(0.05), + "calls": 1, + "ceiling_usd": pytest.approx(0.05), + } + row = json.loads(cache.COST_LOG_PATH.read_text().splitlines()[-1]) + assert row["cost_basis"] == "upto_ceiling" + + def test_settled_amount_from_payment_response_is_booked(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[ok(amount="1234")]) + client = sync_client(gw) + response = client.chat_completion("deepseek/deepseek-chat", MESSAGES) + assert response.cost_usd == pytest.approx(0.001234) + assert response.cost_is_ceiling is False + assert client.get_spending()["ceiling_usd"] == 0.0 + row = json.loads(cache.COST_LOG_PATH.read_text().splitlines()[-1]) + assert row["cost_basis"] == "upto_settled" + + def test_settled_amount_above_ceiling_is_not_trusted(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[ok(amount=str(int(UPTO_CEILING) + 1))]) + response = sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert response.cost_usd == pytest.approx(0.05) + assert response.cost_is_ceiling is True + + def test_exact_rows_unchanged(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + sync_client(Gateway(payment_required())).chat_completion("deepseek/deepseek-chat", MESSAGES) + row = json.loads(cache.COST_LOG_PATH.read_text().splitlines()[-1]) + assert "cost_basis" not in row + + def test_session_limit_counts_the_ceiling(self, monkeypatch): + """Unconfirmed ceilings count against max_session_cost: the cap errs safe.""" + Chain().install(monkeypatch) + gw = Gateway(payment_required()) + client = sync_client(gw, max_session_cost=0.06) + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # upto, books 0.05 + client.chat_completion("deepseek/deepseek-chat", MESSAGES) # ceiling would breach + assert gw.schemes == ["upto", "exact"] + + def test_tx_log_marks_ceiling(self): + row = format_row( + endpoint="/v1/chat/completions", + model="deepseek/deepseek-chat", + in_tokens=1, + out_tokens=1, + cost_usd=0.05, + tx_hash=None, + cost_basis="upto_ceiling", + ) + assert row.endswith("(upto ceiling)") + plain = format_row( + endpoint="/v1/chat/completions", + model="m", + in_tokens=1, + out_tokens=1, + cost_usd=0.05, + tx_hash=None, + ) + assert "ceiling" not in plain + + +# --------------------------------------------------------------------------- +# Streaming +# --------------------------------------------------------------------------- + + +def _sse() -> bytes: + chunk = { + "id": "c", + "object": "chat.completion.chunk", + "created": 1, + "model": "deepseek/deepseek-chat", + "choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": "stop"}], + } + return f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode() + + +def sse_ok() -> httpx.Response: + return httpx.Response( + 200, + content=_sse(), + headers={"content-type": "text/event-stream", "PAYMENT-RESPONSE": settlement_header()}, + ) + + +class TestStreaming: + def test_upto_stream_chunks_are_labeled(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[sse_ok()]) + client = sync_client(gw) + chunks = list(client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto"] + assert chunks and all(c.payment_scheme == "upto" for c in chunks) + assert all(c.cost_is_ceiling is True for c in chunks) + assert client.get_spending()["ceiling_usd"] == pytest.approx(0.05) + + def test_stream_upto_rejected_retries_exact_before_any_chunk(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), sse_ok()]) + client = sync_client(gw) + chunks = list(client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto", "exact"] + assert chunks[0].payment_scheme == "exact" + assert chunks[0].cost_usd == pytest.approx(0.001) + + def test_stream_exact_retry_rejected_raises_once(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), verify_failed(), sse_ok()]) + with pytest.raises(PaymentError): + list(sync_client(gw).chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto", "exact"] + + def test_stream_rejection_after_a_5xx_replay_never_pays_exact(self, monkeypatch): + monkeypatch.setattr("time.sleep", lambda _s: None) + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(502), verify_failed(), sse_ok()]) + with pytest.raises(PaymentError): + list(sync_client(gw).chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert gw.schemes == ["upto", "upto"] + + def test_stream_books_the_ceiling_even_when_the_header_names_an_amount(self, monkeypatch): + # A stream's PAYMENT-RESPONSE is sent before the upto settle, so an + # amount on it is not the settled one. + Chain().install(monkeypatch) + early = httpx.Response( + 200, + content=_sse(), + headers={ + "content-type": "text/event-stream", + "PAYMENT-RESPONSE": settlement_header("700"), + }, + ) + gw = Gateway(payment_required(), paid=[early]) + client = sync_client(gw) + chunks = list(client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + assert chunks[0].cost_is_ceiling is True + assert chunks[0].cost_usd == pytest.approx(0.05) + assert client.get_spending()["ceiling_usd"] == pytest.approx(0.05) + + def test_async_stream_upto_rejected_then_exact(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[verify_failed(), sse_ok()]) + + async def run(): + client = async_client(gw) + return [ + c async for c in client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES) + ] + + chunks = asyncio.run(run()) + assert gw.schemes == ["upto", "exact"] + assert chunks[0].payment_scheme == "exact" + + def test_async_stream_upto(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) + gw = Gateway(payment_required(sponsoring=True), paid=[sse_ok()]) + + async def run(): + client = async_client(gw) + return [ + c async for c in client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES) + ] + + chunks = asyncio.run(run()) + assert gw.schemes == ["upto"] + assert "eip2612GasSponsoring" in gw.signed[0]["extensions"] + assert chunks[0].cost_is_ceiling is True + + +# --------------------------------------------------------------------------- +# Chain reads (eth_call over a mocked RPC) +# --------------------------------------------------------------------------- + + +def _word(n: int) -> str: + return "0x" + format(n, "064x") + + +class TestChainReads: + option = find_upto_option(payment_required()) + sponsored = find_upto_option(payment_required(sponsoring=True)) + + def _rpc(self, answers: dict[str, Any], calls: list[str]): + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + data = body["params"][0]["data"] + calls.append(data[:10]) + answer = answers[data[:10]] + return httpx.Response(200, json=answer) + + return handler + + def test_reads_balance_and_allowance_with_correct_calldata(self): + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert body["params"][0]["to"] == USDC_BASE + seen.append(body["params"][0]["data"]) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": _word(5)}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as http: + state = upto._read_state_sync(http, "https://rpc", self.option, TEST_ACCOUNT.address) + owner = TEST_ACCOUNT.address[2:].lower().rjust(64, "0") + permit2 = PERMIT2_ADDRESS[2:].lower().rjust(64, "0") + assert seen == ["0x70a08231" + owner, "0xdd62ed3e" + owner + permit2] + assert state == UptoChainState(5, 5, None) # no nonce read without sponsoring + + def test_nonce_read_only_when_permit_needed(self): + calls: list[str] = [] + answers = { + "0x70a08231": {"result": _word(10**9)}, + "0xdd62ed3e": {"result": _word(0)}, + "0x7ecebe00": {"result": _word(42)}, + } + with httpx.Client(transport=httpx.MockTransport(self._rpc(answers, calls))) as http: + state = upto._read_state_sync(http, "https://rpc", self.sponsored, TEST_ACCOUNT.address) + assert calls == ["0x70a08231", "0xdd62ed3e", "0x7ecebe00"] + assert state.permit_nonce == 42 + + @pytest.mark.parametrize( + "answer", + [{"error": {"code": -32000, "message": "boom"}}, {"result": "0x"}, {"result": None}], + ) + def test_bad_rpc_answers_raise(self, answer): + with ( + httpx.Client( + transport=httpx.MockTransport(lambda r: httpx.Response(200, json=answer)) + ) as http, + pytest.raises(upto.UptoUnavailable), + ): + upto._read_state_sync(http, "https://rpc", self.option, TEST_ACCOUNT.address) + + def test_falls_through_rpcs(self, monkeypatch): + tried: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + tried.append(str(request.url)) + if "publicnode" in str(request.url): + return httpx.Response(503) + return httpx.Response(200, json={"result": _word(9)}) + + real_client = httpx.Client + monkeypatch.setattr( + upto.httpx, + "Client", + lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw), + ) + state = REAL_READ(self.option, TEST_ACCOUNT.address) + assert state.balance == 9 + assert tried[0].startswith("https://base.publicnode.com") + assert tried[-1].startswith("https://mainnet.base.org") + + def test_async_reads(self, monkeypatch): + answers = {"0x70a08231": _word(10**9), "0xdd62ed3e": _word(0), "0x7ecebe00": _word(3)} + + def handler(request: httpx.Request) -> httpx.Response: + data = json.loads(request.content)["params"][0]["data"] + return httpx.Response(200, json={"result": answers[data[:10]]}) + + real_async = httpx.AsyncClient + monkeypatch.setattr( + upto.httpx, + "AsyncClient", + lambda **kw: real_async(transport=httpx.MockTransport(handler), **kw), + ) + state = asyncio.run(REAL_AREAD(self.sponsored, TEST_ACCOUNT.address)) + assert state == UptoChainState(10**9, 0, 3) + + def test_network_without_rpcs_is_unavailable(self): + """Arc has no pinned public RPC, so it stays exact.""" + arc = find_upto_option( + payment_required( + upto=upto_entry( + network="eip155:5042", asset="0x3600000000000000000000000000000000000000" + ) + ) + ) + assert arc is not None and arc.rpcs == () + with pytest.raises(upto.UptoUnavailable): + REAL_READ(arc, TEST_ACCOUNT.address) + + +# --------------------------------------------------------------------------- +# An upto-only 402 that upto cannot serve is refused, never signed as EIP-3009 +# --------------------------------------------------------------------------- + + +class TestUptoOnly402: + def _upto_only(self) -> dict[str, Any]: + pr = payment_required() + pr["accepts"] = [upto_entry()] + return pr + + def test_exact_preference_refuses_instead_of_signing_the_ceiling(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(self._upto_only()) + with pytest.raises(PaymentError, match="only the x402 'upto' scheme"): + sync_client(gw, payment_scheme="exact").chat_completion( + "deepseek/deepseek-chat", MESSAGES + ) + assert gw.signed == [] + + def test_unusable_upto_refuses_instead_of_signing_the_ceiling(self, monkeypatch): + Chain(allowance=0).install(monkeypatch) # no allowance, no sponsoring + gw = Gateway(self._upto_only()) + with pytest.raises(PaymentError, match="Nothing was signed"): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.signed == [] + + def test_usable_upto_only_402_still_pays_upto(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(self._upto_only()) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + +class TestExactSpendCapOnSignedAmount: + def test_cap_uses_the_signed_amount_not_the_lower_body_price(self, monkeypatch): + # Body price is the base price ($0.0009); the header amount signed is + # $0.001 (base + tx fee). A $0.00095 cap must refuse. + from blockrun_llm.types import SpendLimitError + + Chain().install(monkeypatch) + gw = Gateway(payment_required(with_upto=False), header=False, price_usd="0.0009") + with pytest.raises(SpendLimitError): + sync_client(gw, max_cost_per_call=0.00095).chat_completion( + "deepseek/deepseek-chat", MESSAGES + ) + assert gw.signed == [] + + +# --------------------------------------------------------------------------- +# PAYMENT_REPLAY on the first send is never answered with an exact payment +# --------------------------------------------------------------------------- + + +def replay(**extra: Any) -> httpx.Response: + """The chat route's nonce-claim refusal (blockrun src/lib/payment-nonce.ts).""" + return httpx.Response( + 402, + json={ + "error": "Payment authorization already used", + "message": "Sign a fresh payment authorization for each request.", + "code": "PAYMENT_REPLAY", + "payer": TEST_ACCOUNT.address, + **extra, + }, + ) + + +def _assert_replay_error(exc: PaymentError) -> None: + assert "PAYMENT_REPLAY" in str(exc) + assert "Check your wallet balance" not in str(exc) + assert exc.status_code == 402 + assert exc.response is not None and exc.response["code"] == "PAYMENT_REPLAY" + + +class TestReplayNeverPaysExact: + """A first-send ``PAYMENT_REPLAY`` means the authorization was already used + by an earlier request that was served and paid (e.g. a proxy duplicated + the send). Paying exact on top would charge twice.""" + + def test_sync(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), ok()]) + client = sync_client(gw) + with pytest.raises(PaymentError) as exc: + client.chat_completion("deepseek/deepseek-chat", MESSAGES) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] # exactly one paid POST + assert client._upto_rejected == {} + + def test_async(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), ok()]) + + async def run(): + await async_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + + with pytest.raises(PaymentError) as exc: + asyncio.run(run()) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] + + def test_sync_stream(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), sse_ok()]) + with pytest.raises(PaymentError) as exc: + list(sync_client(gw).chat_completion_stream("deepseek/deepseek-chat", MESSAGES)) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] + + def test_async_stream(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[replay(), sse_ok()]) + + async def run(): + client = async_client(gw) + return [ + c async for c in client.chat_completion_stream("deepseek/deepseek-chat", MESSAGES) + ] + + with pytest.raises(PaymentError) as exc: + asyncio.run(run()) + _assert_replay_error(exc.value) + assert gw.schemes == ["upto"] + + def test_recoverable_prior_use_keeps_the_poll_url(self, monkeypatch): + Chain().install(monkeypatch) + prior = replay( + message="This authorization was already used by an earlier request.", + job_id="job_1", + poll_url="/api/v1/jobs/job_1", + recoverable=True, + ) + gw = Gateway(payment_required(), paid=[prior, ok()]) + with pytest.raises(PaymentError) as exc: + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + assert "/api/v1/jobs/job_1" in str(exc.value) + assert "already used by an earlier request" in str(exc.value) + assert exc.value.response["poll_url"] == "/api/v1/jobs/job_1" + assert exc.value.response["recoverable"] is True + + def test_prior_use_markers_without_the_code_still_block_exact(self, monkeypatch): + Chain().install(monkeypatch) + body = {"error": "Payment verification failed", "poll_url": "/api/v1/jobs/j"} + gw = Gateway(payment_required(), paid=[httpx.Response(402, json=body), ok()]) + with pytest.raises(PaymentError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + def test_unclassified_402_is_not_a_verify_failure(self, monkeypatch): + Chain().install(monkeypatch) + gw = Gateway(payment_required(), paid=[httpx.Response(402, json={"error": "?"}), ok()]) + with pytest.raises(PaymentError): + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto"] + + def test_verify_failure_codes_still_fall_back(self, monkeypatch): + Chain().install(monkeypatch) + unfunded = httpx.Response( + 402, json={"error": "Payment verification failed", "code": "PAYMENT_UNFUNDED"} + ) + gw = Gateway(payment_required(), paid=[unfunded, ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + + def test_fresh_challenge_402_still_falls_back(self, monkeypatch): + # The gateway answered the upto payment with a new challenge: it was + # not accepted at all, so nothing was used. + Chain().install(monkeypatch) + challenge = httpx.Response( + 402, + json={"error": "Payment Required"}, + headers={"payment-required": b64(payment_required())}, + ) + gw = Gateway(payment_required(), paid=[challenge, ok()]) + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert gw.schemes == ["upto", "exact"] + + def test_upto_only_402_refused_by_the_gateway_says_so(self, monkeypatch): + Chain().install(monkeypatch) + pr = payment_required() + pr["accepts"] = [upto_entry()] + gw = Gateway(pr, paid=[verify_failed(), ok()]) + with pytest.raises(PaymentError, match="the gateway refused it") as exc: + sync_client(gw).chat_completion("deepseek/deepseek-chat", MESSAGES) + assert "Nothing was signed" not in str(exc.value) + assert gw.schemes == ["upto"] + + +# --------------------------------------------------------------------------- +# The shared EIP-3009 signer never signs an upto requirement +# --------------------------------------------------------------------------- + + +class TestSharedSignerRefusesUpto: + def _upto_only(self) -> dict[str, Any]: + pr = payment_required() + pr["accepts"] = [upto_entry()] + return pr + + def test_extract_payment_details_refuses_upto_only(self): + from blockrun_llm.x402 import extract_payment_details + + with pytest.raises(ValueError, match="only the x402 'upto' scheme"): + extract_payment_details(self._upto_only()) + + def test_extract_payment_details_allow_upto_returns_it(self): + from blockrun_llm.x402 import extract_payment_details + + details = extract_payment_details(self._upto_only(), allow_upto=True) + assert details["scheme"] == "upto" + assert details["amount"] == UPTO_CEILING + + def test_extract_payment_details_prefers_exact_over_a_leading_upto(self): + from blockrun_llm.x402 import extract_payment_details + + pr = payment_required() + pr["accepts"] = [upto_entry(), exact_entry()] + assert extract_payment_details(pr)["scheme"] == "exact" + + def test_create_payment_payload_refuses_a_non_exact_scheme(self): + from blockrun_llm.x402 import create_payment_payload + + with pytest.raises(ValueError, match="cannot be signed as an EIP-3009"): + create_payment_payload( + TEST_ACCOUNT, TEST_RECIPIENT, UPTO_CEILING, asset=USDC_BASE, scheme="upto" + ) + + def test_create_payment_payload_default_and_v1_none_are_exact(self): + from blockrun_llm.x402 import create_payment_payload + + for kwargs in ({}, {"scheme": None}, {"scheme": "exact"}): + payload = decode(create_payment_payload(TEST_ACCOUNT, TEST_RECIPIENT, "1000", **kwargs)) + assert payload["accepted"]["scheme"] == "exact" + + def test_image_client_signs_nothing_for_an_upto_only_402(self): + from blockrun_llm.image import ImageClient + + gw = Gateway(self._upto_only()) + client = ImageClient(private_key=TEST_PRIVATE_KEY) + client._client = httpx.Client(transport=httpx.MockTransport(gw)) + with pytest.raises(ValueError, match="only the x402 'upto' scheme"): + client.generate("a cat") + assert gw.signed == [] diff --git a/tests/unit/x402_upto_reference_vector.json b/tests/unit/x402_upto_reference_vector.json new file mode 100644 index 0000000..66032bd --- /dev/null +++ b/tests/unit/x402_upto_reference_vector.json @@ -0,0 +1,89 @@ +{ + "source": "@x402/evm 2.28.0 UptoEvmScheme (viem 2.56.3)", + "inputs": { + "privateKey": "hardhat #0", + "nowSeconds": 1790000000, + "randomBytes": "0x11 * 32", + "requirements": { + "scheme": "upto", + "network": "eip155:8453", + "amount": "123456", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USD Coin", + "version": "2", + "facilitatorAddress": "0x97AcCe27D5069544480BDe0F04D9F47d7422a016" + } + }, + "usdcNonce": "7", + "allowance": "0" + }, + "constants": { + "PERMIT2_ADDRESS": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + "x402UptoPermit2ProxyAddress": "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002" + }, + "bare": { + "x402Version": 2, + "payload": { + "signature": "0x5ff801e766700c7f476d107973c648fc4299ce414ddc0be54a16f4d6aec07ce421e36e59d4d768f5056c0d2e5e7539353d24a87075de9d3e53899a8bf6671a5f1b", + "permit2Authorization": { + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123456" + }, + "spender": "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002", + "nonce": "7719472615821079694904732333912527190217998977709370935963838933860875309329", + "deadline": "1790000300", + "witness": { + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "facilitator": "0x97AcCe27D5069544480BDe0F04D9F47d7422a016", + "validAfter": "0" + } + } + } + }, + "sponsored": { + "x402Version": 2, + "payload": { + "signature": "0x5ff801e766700c7f476d107973c648fc4299ce414ddc0be54a16f4d6aec07ce421e36e59d4d768f5056c0d2e5e7539353d24a87075de9d3e53899a8bf6671a5f1b", + "permit2Authorization": { + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "123456" + }, + "spender": "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002", + "nonce": "7719472615821079694904732333912527190217998977709370935963838933860875309329", + "deadline": "1790000300", + "witness": { + "to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "facilitator": "0x97AcCe27D5069544480BDe0F04D9F47d7422a016", + "validAfter": "0" + } + } + }, + "extensions": { + "eip2612GasSponsoring": { + "info": { + "from": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "spender": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + "amount": "123456", + "nonce": "7", + "deadline": "1790000300", + "signature": "0x4064c682120c9f01f9491bc73f0f786742cda0e6382d8f040399ca317ca0282023aa153f6852f7879175539ae63cff5501b9c413b5158201a36e2e57b3e399be1c", + "version": "1" + } + } + } + }, + "permit2Digest": "0x94ab2e45bed05e03c203e5ee7397f25bb42f2e962ab5b55c86a31206f9cfae9c", + "eip2612Digest": "0x46d46661b7f43243a2686ffe061b0154464c5c30fb8902b5c31cc3fc18b22a35", + "reads": [ + "allowance", + "nonces" + ] +}