From 35706117bfd5d9c9caa3cfa6f908f1b5b1d00892 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Thu, 16 Jul 2026 00:23:11 -0500 Subject: [PATCH 1/2] fix(errors): stop claiming payment was taken when nothing settled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "API error after payment" reads as *your money is gone*, and on almost every failure that is false. Gateways settle on success: settlePaymentWithRetry sits after the upstream work, so a failed paid request normally moves no funds at all. The message said otherwise on all 26 error paths. This is not theoretical. A 500 from an image edit was read as a lost payment by two separate readers and reported as real spend, before anyone checked the gateway's settle ordering. The wording alone manufactured the false alarm, and cost real time chasing money that never left the wallet. The SDK can do better than guess: X-PAYMENT-RESPONSE carries the on-chain settlement, and its absence is the ordinary shape of a failure that cost nothing. paid_request_error_prefix() reads it and reports what is known: settled -> "API error after settlement (payment SETTLED, tx 0xabc...)" not settled -> "API error on the paid request (no settlement recorded — payment likely not taken)" Absence isn't proof — a gateway could settle and omit the header — so the wording stays hedged rather than promising a refund that isn't ours to give. Applied across all 13 modules that had the phrase; the helper lives in tx_log next to decode_settlement_header, which it uses. Error paths must never raise, so a malformed or missing header degrades to the unsettled wording. --- blockrun_llm/client.py | 21 +++--- blockrun_llm/image.py | 3 +- blockrun_llm/music.py | 3 +- blockrun_llm/phone.py | 3 +- blockrun_llm/portrait.py | 3 +- blockrun_llm/price.py | 3 +- blockrun_llm/realface.py | 5 +- blockrun_llm/rpc.py | 3 +- blockrun_llm/search.py | 3 +- blockrun_llm/solana_client.py | 25 ++++--- blockrun_llm/speech.py | 3 +- blockrun_llm/surf.py | 3 +- blockrun_llm/tx_log.py | 33 +++++++++ blockrun_llm/voice.py | 3 +- tests/unit/test_paid_request_error_prefix.py | 70 ++++++++++++++++++++ 15 files changed, 155 insertions(+), 29 deletions(-) create mode 100644 tests/unit/test_paid_request_error_prefix.py diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index b4cd5e8..e36f28b 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -61,7 +61,12 @@ chunk_usage_dict, ) from .router import route as route_request -from .tx_log import TransactionLogger, decode_settlement_header, _resolve_log_dir +from .tx_log import ( + TransactionLogger, + decode_settlement_header, + paid_request_error_prefix, + _resolve_log_dir, +) from .x402 import create_payment_payload, parse_payment_required, extract_payment_details from .validation import ( validate_private_key, @@ -1050,7 +1055,7 @@ def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> Non error_body = response.json() except Exception: error_body = {"error": "Stream request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, @@ -1199,7 +1204,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1366,7 +1371,7 @@ def _handle_payment_and_retry_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1504,7 +1509,7 @@ def _handle_get_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -2765,7 +2770,7 @@ async def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -2920,7 +2925,7 @@ async def _handle_payment_and_retry_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -3044,7 +3049,7 @@ async def _handle_get_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index c46f18c..2084f56 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -41,6 +41,7 @@ validate_private_key, validate_resource_url, ) +from .tx_log import paid_request_error_prefix # Load environment variables @@ -365,7 +366,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/music.py b/blockrun_llm/music.py index 1124e91..16058db 100644 --- a/blockrun_llm/music.py +++ b/blockrun_llm/music.py @@ -42,6 +42,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -238,7 +239,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/phone.py b/blockrun_llm/phone.py index f391d45..f3ee5e1 100644 --- a/blockrun_llm/phone.py +++ b/blockrun_llm/phone.py @@ -56,6 +56,7 @@ extract_payment_details, parse_payment_required, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -300,7 +301,7 @@ def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> Dict[st error_body = response.json() except Exception: error_body = {"error": "Request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, diff --git a/blockrun_llm/portrait.py b/blockrun_llm/portrait.py index d90939d..395bb43 100644 --- a/blockrun_llm/portrait.py +++ b/blockrun_llm/portrait.py @@ -59,6 +59,7 @@ sanitize_error_response, validate_resource_url, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -276,7 +277,7 @@ def _handle_payment_and_retry( ) if retry.status_code != 200: - self._raise_api_error(retry, "Enrollment failed after payment") + self._raise_api_error(retry, f"Enrollment: {paid_request_error_prefix(retry.headers)}") return PortraitEnrollment(**retry.json()) diff --git a/blockrun_llm/price.py b/blockrun_llm/price.py index 45ea083..91b3e89 100644 --- a/blockrun_llm/price.py +++ b/blockrun_llm/price.py @@ -46,6 +46,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -285,7 +286,7 @@ def _pay_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry.status_code}", + f"{paid_request_error_prefix(retry.headers)}: {retry.status_code}", retry.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/realface.py b/blockrun_llm/realface.py index e0bb226..d51f3ec 100644 --- a/blockrun_llm/realface.py +++ b/blockrun_llm/realface.py @@ -82,6 +82,7 @@ sanitize_error_response, validate_resource_url, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -443,7 +444,9 @@ def _handle_payment_and_retry( ) if retry.status_code != 200: - self._raise_api_error(retry, "RealFace enrollment failed after payment") + self._raise_api_error( + retry, f"RealFace enrollment: {paid_request_error_prefix(retry.headers)}" + ) return RealFaceEnrollment(**retry.json()) diff --git a/blockrun_llm/rpc.py b/blockrun_llm/rpc.py index 5ec2dbd..20369e4 100644 --- a/blockrun_llm/rpc.py +++ b/blockrun_llm/rpc.py @@ -59,6 +59,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -378,7 +379,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/search.py b/blockrun_llm/search.py index 8549790..0e4882d 100644 --- a/blockrun_llm/search.py +++ b/blockrun_llm/search.py @@ -32,6 +32,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -195,7 +196,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry.status_code}", + f"{paid_request_error_prefix(retry.headers)}: {retry.status_code}", retry.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 1ea6ffd..d6dadce 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -53,7 +53,12 @@ chunk_usage_dict, ) from .solana_wallet import get_solana_public_key -from .tx_log import TransactionLogger, decode_settlement_header, _resolve_log_dir +from .tx_log import ( + TransactionLogger, + decode_settlement_header, + paid_request_error_prefix, + _resolve_log_dir, +) from .price import Category, Market, Resolution, Session from .realface import _GROUP_ID_RE from .validation import ( @@ -1067,7 +1072,7 @@ def _raise_stream_error(response: httpx.Response, *, after_payment: bool) -> Non error_body = response.json() except Exception: error_body = {"error": "Stream request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, @@ -1177,7 +1182,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1301,7 +1306,7 @@ def _handle_payment_and_retry_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1408,7 +1413,7 @@ def _handle_get_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -1567,7 +1572,7 @@ def _request_image_with_payment( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"Image request after payment: HTTP {submit_resp.status_code}", + f"Image request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", submit_resp.status_code, sanitize_error_response(error_body), ) @@ -3347,7 +3352,7 @@ async def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -3414,7 +3419,7 @@ async def _request_with_payment_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -3483,7 +3488,7 @@ async def _get_with_payment_raw( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) @@ -4187,7 +4192,7 @@ async def _request_image_with_payment( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"Image request after payment: HTTP {submit_resp.status_code}", + f"Image request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", submit_resp.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/speech.py b/blockrun_llm/speech.py index fb3eb98..97f7fdd 100644 --- a/blockrun_llm/speech.py +++ b/blockrun_llm/speech.py @@ -48,6 +48,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -327,7 +328,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/blockrun_llm/surf.py b/blockrun_llm/surf.py index b4a643a..2711b48 100644 --- a/blockrun_llm/surf.py +++ b/blockrun_llm/surf.py @@ -53,6 +53,7 @@ extract_payment_details, parse_payment_required, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -394,7 +395,7 @@ def _unwrap(response: httpx.Response, *, after_payment: bool = False) -> Dict[st error_body = response.json() except Exception: error_body = {"error": "Request failed"} - prefix = "API error after payment" if after_payment else "API error" + prefix = paid_request_error_prefix(response.headers) if after_payment else "API error" raise APIError( f"{prefix}: {response.status_code}", response.status_code, diff --git a/blockrun_llm/tx_log.py b/blockrun_llm/tx_log.py index a29b03e..096cbfe 100644 --- a/blockrun_llm/tx_log.py +++ b/blockrun_llm/tx_log.py @@ -84,6 +84,39 @@ def decode_settlement_header(header_value: Optional[str]) -> Optional[Dict[str, } +def paid_request_error_prefix(headers: Any) -> str: + """Error prefix for a failed request that carried a payment header. + + This used to be the flat string "API error after payment", which reads as + *your money is gone* — and that is usually false. Gateways settle **on + success**: the settle call sits after the upstream work, so a failed paid + request normally moves no funds at all. The old wording claimed otherwise on + every failure. + + Not hypothetical: a 500 from an image edit was read as a lost payment by two + separate readers and reported as real spend, before anyone checked the + gateway's settle ordering. The wording alone manufactured the false alarm. + + So report only what is known. ``X-PAYMENT-RESPONSE`` carries the on-chain + settlement, and its absence is the ordinary shape of a failure that cost + nothing: + + * settlement present → funds **did** move; say so, and name the tx. + * settlement absent → the paid attempt failed with nothing settled. + + Absence isn't proof (a gateway could settle and omit the header), so the + wording stays hedged rather than promising a refund that isn't ours to give. + """ + settlement = None + try: + settlement = decode_settlement_header(headers.get("X-PAYMENT-RESPONSE")) + except Exception: + settlement = None + if settlement and settlement.get("tx_hash"): + return f"API error after settlement (payment SETTLED, tx {settlement['tx_hash']})" + return "API error on the paid request (no settlement recorded — payment likely not taken)" + + # --------------------------------------------------------------------------- # Path resolution # --------------------------------------------------------------------------- diff --git a/blockrun_llm/voice.py b/blockrun_llm/voice.py index b0e21e3..db39288 100644 --- a/blockrun_llm/voice.py +++ b/blockrun_llm/voice.py @@ -46,6 +46,7 @@ validate_api_url, sanitize_error_response, ) +from .tx_log import paid_request_error_prefix load_dotenv() @@ -339,7 +340,7 @@ def _handle_payment_and_retry( except Exception: error_body = {"error": "Request failed"} raise APIError( - f"API error after payment: {retry_response.status_code}", + f"{paid_request_error_prefix(retry_response.headers)}: {retry_response.status_code}", retry_response.status_code, sanitize_error_response(error_body), ) diff --git a/tests/unit/test_paid_request_error_prefix.py b/tests/unit/test_paid_request_error_prefix.py new file mode 100644 index 0000000..be873bb --- /dev/null +++ b/tests/unit/test_paid_request_error_prefix.py @@ -0,0 +1,70 @@ +"""The paid-request error message must not claim money moved when it didn't. + +"API error after payment" read as *your funds are gone* on every failure. That +is usually false — gateways settle on success, so the settle call sits after the +upstream work and a failed paid request normally moves nothing. + +This is a regression test for a real misdiagnosis: an image-edit 500 was +reported as lost USDC by two readers before anyone checked the gateway's settle +ordering. The wording alone caused it. +""" + +import base64 +import json + +import httpx +import pytest + +from blockrun_llm.tx_log import paid_request_error_prefix + + +def _settlement_header(tx_hash="0xabc123", **extra): + payload = {"transaction": tx_hash, "network": "base", "success": True, **extra} + return base64.b64encode(json.dumps(payload).encode()).decode() + + +class TestUnsettled: + """No settlement header = the ordinary shape of a failure that cost nothing.""" + + def test_no_header_does_not_claim_payment_was_taken(self): + msg = paid_request_error_prefix(httpx.Headers({})) + assert "no settlement recorded" in msg + assert "SETTLED" not in msg + # The specific phrase that caused the false alarm must be gone. + assert "after payment" not in msg + + @pytest.mark.parametrize( + "bad", ["", "!!!not-base64!!!", "e30=", base64.b64encode(b"[]").decode()] + ) + def test_unparseable_header_degrades_to_unsettled(self, bad): + """An error path must never raise while reporting an error.""" + msg = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": bad})) + assert "no settlement recorded" in msg + + def test_header_without_tx_hash_is_not_a_settlement(self): + payload = base64.b64encode(json.dumps({"network": "base"}).encode()).decode() + msg = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": payload})) + assert "no settlement recorded" in msg + + +class TestSettled: + """A settlement header means funds really did move — say so, and name the tx.""" + + def test_reports_settlement_and_tx_hash(self): + msg = paid_request_error_prefix( + httpx.Headers({"X-PAYMENT-RESPONSE": _settlement_header("0xdeadbeef")}) + ) + assert "SETTLED" in msg + assert "0xdeadbeef" in msg, "the tx hash is what makes this actionable" + + def test_solana_signature_field_also_counts(self): + payload = base64.b64encode(json.dumps({"signature": "5xSolSig"}).encode()).decode() + msg = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": payload})) + assert "SETTLED" in msg and "5xSolSig" in msg + + +def test_the_two_cases_are_distinguishable(): + """The whole point: they used to be the same string.""" + unsettled = paid_request_error_prefix(httpx.Headers({})) + settled = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": _settlement_header()})) + assert unsettled != settled From 705fb2920c0aa8ec6786502933c2b5c0ed09ab82 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Thu, 16 Jul 2026 00:39:52 -0500 Subject: [PATCH 2/2] fix(errors): read the header gateways actually send; don't assert "not taken" (1.7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the first cut, both found by checking the gateways instead of the PR's own reasoning. 1. The header name was wrong. Both gateways send PAYMENT-RESPONSE (x402 v2) — blockrun 36 sites, blockrun-sol 25 — and neither ever sends X-PAYMENT-RESPONSE. The SDK read only the legacy name in four hand-rolled places, so settlement decoded to nothing against production and the "SETTLED" branch was dead code. The tests passed because they built the legacy name themselves, mirroring the bug. Now one helper reads both. 2. "payment likely not taken" is a false all-clear. Solana's paid chat path settles in parallel and re-raises at once (logChargedButFailed(...); throw primaryError), so a request the gateway logs as CHARGED BUT REQUEST FAILED — refund manually answers before settle lands and carries no header. Absence and "you were charged" co-occur systematically there. Base does settle after upstream, but headers don't say which gateway you hit. The wording names the usual case without asserting it. Also fixes settlement capture in client.py/solana_client.py, which had the same wrong name — pre-existing, and the reason _last_settlement never populated on real calls. --- CHANGELOG.md | 39 +++++++ blockrun_llm/__init__.py | 2 +- blockrun_llm/client.py | 13 +-- blockrun_llm/solana_client.py | 18 +-- blockrun_llm/tx_log.py | 86 ++++++++++---- pyproject.toml | 2 +- tests/unit/test_paid_request_error_prefix.py | 111 +++++++++++++++---- 7 files changed, 212 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c780e59..d4b3c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to blockrun-llm will be documented in this file. +## 1.7.1 — 2026-07-16 + +### Fixed + +- **The settlement header was read under a name no gateway sends.** Both + gateways emit `PAYMENT-RESPONSE` (the x402 v2 spec name) — `blockrun` at 36 + call sites, `blockrun-sol` at 25 — and neither emits `X-PAYMENT-RESPONSE` + even once. The SDK read only the legacy name, in four hand-rolled places, so + `_last_settlement` decoded nothing against production: no tx hash, no + settlement on any paid call. The sidecar hit this exact bug and fixed it in + blockrun-litellm 0.6.0, live-verified against a real paid call; the SDK half + was never done. Both names now go through one helper + (`tx_log.read_settlement_header`) so they can't drift apart again. The legacy + name stays accepted for other facilitators. + +- **The paid-request error no longer claims your money is gone.** + `"API error after payment"` reads as *funds are lost*, which is usually false + — a real image-edit 500 was reported as lost USDC by two readers before + anyone checked the gateway. It now reports only what the settlement header + proves: a tx hash means SETTLED and is named; absence means unknown. + + Absence is **not** reported as "payment likely not taken", which the first cut + of this change did. That trades a false alarm for a false all-clear, and the + all-clear lands on precisely the wrong requests: Solana's paid chat path + settles *in parallel* with the upstream call and re-raises immediately + (`logChargedButFailed(...); throw primaryError`), so a request the gateway + logs as `CHARGED BUT REQUEST FAILED — refund manually` answers *before* + settlement lands, and therefore carries no header at all. Absence and "you + were charged" co-occur systematically on the one path where it costs money. + Base settles after the upstream call and does match the optimistic reading, + but a set of headers doesn't tell the SDK which gateway produced it. So the + wording names the usual case without asserting it, and points at wallet + history. + + Gated on `tx_hash`, never the header's `success` field: the gateways hard-code + `success: true` even when settle didn't land, so older clients don't surface a + spurious error. A tx hash is the only field that means money moved — the same + field the gateways gate their own revenue accounting on. + ## 1.7.0 — 2026-07-15 ### Added diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index c33f15a..bfefbc8 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -170,7 +170,7 @@ ) from .tx_log import TransactionLogger, decode_settlement_header, format_row -__version__ = "1.7.0" +__version__ = "1.7.1" __all__ = [ "LLMClient", "AsyncLLMClient", diff --git a/blockrun_llm/client.py b/blockrun_llm/client.py index e36f28b..4a3084e 100644 --- a/blockrun_llm/client.py +++ b/blockrun_llm/client.py @@ -65,6 +65,7 @@ TransactionLogger, decode_settlement_header, paid_request_error_prefix, + read_settlement_header, _resolve_log_dir, ) from .x402 import create_payment_payload, parse_payment_required, extract_payment_details @@ -315,7 +316,7 @@ def __init__( self._model_pricing_cache: Optional[Dict[str, Dict[str, float]]] = None # Opt-in transaction log + last on-chain settlement payload. The - # settlement is populated from X-PAYMENT-RESPONSE on every paid retry + # settlement is populated from PAYMENT-RESPONSE on every paid retry # and cleared right before save_to_cache fires so it can't bleed # across calls when logging is disabled. log_dir = _resolve_log_dir(transaction_log) @@ -332,9 +333,7 @@ def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, An ``save_to_cache``. ``None`` when the facilitator didn't include a settlement header — older facilitators / cached free responses. """ - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement @@ -2087,7 +2086,7 @@ def _log_transaction( """Append one row to the project-local transaction log, if enabled. Pulls the on-chain settlement out of ``self._last_settlement`` - (captured from ``X-PAYMENT-RESPONSE`` on the paid retry) and + (captured from ``PAYMENT-RESPONSE`` on the paid retry) and consumes it — so a subsequent free / cached call right after a paid one cannot reuse stale tx fields. No-op when the logger is disabled; never raises (best-effort logging by design).""" @@ -2276,9 +2275,7 @@ def __init__( def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: """Async-client twin of :meth:`LLMClient._capture_settlement`.""" - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index d6dadce..ea0bc67 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -57,6 +57,7 @@ TransactionLogger, decode_settlement_header, paid_request_error_prefix, + read_settlement_header, _resolve_log_dir, ) from .price import Category, Market, Resolution, Session @@ -564,12 +565,15 @@ def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, An """Decode the x402 settlement header on a Solana paid response. Solana facilitators put the on-chain transaction signature in the - same ``X-PAYMENT-RESPONSE`` header EVM does — different chain id, - same wire format. ``None`` when no header is returned. + same ``PAYMENT-RESPONSE`` header EVM does — different chain id, same + wire format. ``None`` when no header is returned. + + Absence does NOT mean the call was free: this gateway's paid chat + path settles in parallel with the upstream call and re-raises at + once, so a charged-but-failed request answers before settlement + lands. See ``paid_request_error_prefix``. """ - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement @@ -2845,9 +2849,7 @@ async def _sign_payment(self, payment_required: Any) -> Any: def _capture_settlement(self, response: httpx.Response) -> Optional[Dict[str, Any]]: """Async-Solana twin of :meth:`SolanaLLMClient._capture_settlement`.""" - header = response.headers.get("x-payment-response") or response.headers.get( - "X-PAYMENT-RESPONSE" - ) + header = read_settlement_header(response.headers) settlement = decode_settlement_header(header) self._last_settlement = settlement return settlement diff --git a/blockrun_llm/tx_log.py b/blockrun_llm/tx_log.py index 096cbfe..1fe2ece 100644 --- a/blockrun_llm/tx_log.py +++ b/blockrun_llm/tx_log.py @@ -44,12 +44,42 @@ # --------------------------------------------------------------------------- -# Settlement header decoding (X-PAYMENT-RESPONSE → on-chain dict) +# Settlement header decoding (PAYMENT-RESPONSE → on-chain dict) # --------------------------------------------------------------------------- +# The settlement header has two names on the wire, and the one our gateways +# actually send is NOT the one this SDK was written against. Both BlockRun +# gateways emit ``PAYMENT-RESPONSE`` (the x402 v2 spec name) and neither ever +# emits ``X-PAYMENT-RESPONSE``; reading only the legacy name decodes nothing at +# all against production. The sidecar hit exactly this and fixed it in +# blockrun-litellm 0.6.0, live-verified against a real paid call. +# +# The legacy name stays accepted: other x402 facilitators still send it, and an +# unknown header costs nothing to check. Order matters only if both are present, +# in which case the spec name wins. +_SETTLEMENT_HEADER_NAMES = ("PAYMENT-RESPONSE", "X-PAYMENT-RESPONSE") + + +def read_settlement_header(headers: Any) -> Optional[str]: + """Pull the raw settlement header out of a response, under either name. + + Single source of truth for the header name — call sites must not hand-roll + the fallback, which is how the SDK ended up reading only the legacy name in + four separate places. Never raises: a header mapping that doesn't behave + like one yields ``None`` rather than exploding on an error path. + """ + try: + for name in _SETTLEMENT_HEADER_NAMES: + value = headers.get(name) + if value: + return value + except Exception: + return None + return None + def decode_settlement_header(header_value: Optional[str]) -> Optional[Dict[str, Any]]: - """Decode an ``X-PAYMENT-RESPONSE`` header into a settlement dict. + """Decode a ``PAYMENT-RESPONSE`` header into a settlement dict. The x402 facilitator returns a base64-encoded JSON describing what landed on chain. Field names vary by chain — EVM uses ``transaction``, @@ -88,33 +118,51 @@ def paid_request_error_prefix(headers: Any) -> str: """Error prefix for a failed request that carried a payment header. This used to be the flat string "API error after payment", which reads as - *your money is gone* — and that is usually false. Gateways settle **on - success**: the settle call sits after the upstream work, so a failed paid - request normally moves no funds at all. The old wording claimed otherwise on - every failure. + *your money is gone* — usually false, and it cost real time: a 500 from an + image edit was read as a lost payment by two separate readers and reported + as real spend before anyone checked the gateway. The wording manufactured + the false alarm. - Not hypothetical: a 500 from an image edit was read as a lost payment by two - separate readers and reported as real spend, before anyone checked the - gateway's settle ordering. The wording alone manufactured the false alarm. - - So report only what is known. ``X-PAYMENT-RESPONSE`` carries the on-chain - settlement, and its absence is the ordinary shape of a failure that cost - nothing: + The fix is to report only what is known, which is less than it looks: * settlement present → funds **did** move; say so, and name the tx. - * settlement absent → the paid attempt failed with nothing settled. - - Absence isn't proof (a gateway could settle and omit the header), so the - wording stays hedged rather than promising a refund that isn't ours to give. + * settlement absent → **unknown**, and it must not be read as "free". + + Absence is genuinely uninformative, in two ways that bite: + + 1. Base settles synchronously after the upstream call, so absence there + usually does mean nothing moved. Solana's paid chat path settles + *in parallel* with the upstream call and re-raises immediately + (``logChargedButFailed(...); throw primaryError``) — the response is on + the wire before settlement lands. So on the one path where the caller is + charged for a 5xx and the gateway logs ``CHARGED BUT REQUEST FAILED — + refund manually``, the error carries **no header at all**. Absence and + "you were charged" co-occur *systematically*, not by chance. + 2. A gateway could always settle and omit the header. + + Hence the hedge names the usual case without asserting it. Claiming "payment + likely not taken" would replace a false alarm with a false all-clear, on + exactly the requests that need a manual refund — the worse of the two errors + for anyone reconciling spend. + + Gated on ``tx_hash``, never on the header's ``success`` field: our gateways + hard-code ``success: true`` even when settle didn't land, so that clients + parsing the header don't surface a spurious error. A tx hash is the only + thing in there that means money moved — the gateways gate their own revenue + accounting on exactly the same field. """ settlement = None try: - settlement = decode_settlement_header(headers.get("X-PAYMENT-RESPONSE")) + settlement = decode_settlement_header(read_settlement_header(headers)) except Exception: settlement = None if settlement and settlement.get("tx_hash"): return f"API error after settlement (payment SETTLED, tx {settlement['tx_hash']})" - return "API error on the paid request (no settlement recorded — payment likely not taken)" + return ( + "API error on the paid request (no settlement reported — a failed call " + "usually moves no funds, but settlement can land after the error; check " + "your wallet history before assuming nothing was charged)" + ) # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 6aa8f94..bf71973 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.7.0" +version = "1.7.1" description = "BlockRun SDK - Pay-per-request AI (LLM, Image, Video, Music, Voice) via x402 on Base and Solana" readme = "README.md" license = "MIT" diff --git a/tests/unit/test_paid_request_error_prefix.py b/tests/unit/test_paid_request_error_prefix.py index be873bb..f023e42 100644 --- a/tests/unit/test_paid_request_error_prefix.py +++ b/tests/unit/test_paid_request_error_prefix.py @@ -1,12 +1,19 @@ -"""The paid-request error message must not claim money moved when it didn't. - -"API error after payment" read as *your funds are gone* on every failure. That -is usually false — gateways settle on success, so the settle call sits after the -upstream work and a failed paid request normally moves nothing. - -This is a regression test for a real misdiagnosis: an image-edit 500 was -reported as lost USDC by two readers before anyone checked the gateway's settle -ordering. The wording alone caused it. +"""The paid-request error message must not claim money moved — or that it didn't. + +"API error after payment" read as *your funds are gone* on every failure, which +is usually false. This is a regression test for a real misdiagnosis: an +image-edit 500 was reported as lost USDC by two readers before anyone checked +the gateway's settle ordering. The wording alone caused it. + +The second half of the file guards the opposite error, which is worse. Both +gateways send the settlement under the x402 v2 name ``PAYMENT-RESPONSE`` and +neither ever sends ``X-PAYMENT-RESPONSE`` — so reading only the legacy name +decodes nothing in production and reports every settled failure as unsettled. +And on Solana's paid chat path, settle runs in parallel with the upstream call +and the error re-raises before it lands, so the requests that DID charge (the +ones the gateway logs as ``CHARGED BUT REQUEST FAILED — refund manually``) are +exactly the ones arriving with no header. Absence cannot be sold as "you weren't +charged". """ import base64 @@ -15,7 +22,13 @@ import httpx import pytest -from blockrun_llm.tx_log import paid_request_error_prefix +from blockrun_llm.tx_log import paid_request_error_prefix, read_settlement_header + +# What our gateways actually send (x402 v2). The legacy name is still accepted +# for other facilitators, so both are exercised everywhere it matters. +SPEC_NAME = "PAYMENT-RESPONSE" +LEGACY_NAME = "X-PAYMENT-RESPONSE" +BOTH_NAMES = [SPEC_NAME, LEGACY_NAME] def _settlement_header(tx_hash="0xabc123", **extra): @@ -23,28 +36,81 @@ def _settlement_header(tx_hash="0xabc123", **extra): return base64.b64encode(json.dumps(payload).encode()).decode() +class TestHeaderName: + """The bug that made the whole mechanism dead code in production.""" + + def test_spec_name_is_read(self): + """Regression: the SDK read only the legacy name, which no gateway sends. + + blockrun and blockrun-sol emit `PAYMENT-RESPONSE` (36 and 25 call sites + respectively) and `X-PAYMENT-RESPONSE` zero times. The sidecar hit this + in blockrun-litellm 0.6.0, live-verified against a real paid call. + """ + msg = paid_request_error_prefix(httpx.Headers({SPEC_NAME: _settlement_header("0xfeed")})) + assert "SETTLED" in msg and "0xfeed" in msg + + def test_legacy_name_still_accepted(self): + msg = paid_request_error_prefix(httpx.Headers({LEGACY_NAME: _settlement_header("0xbeef")})) + assert "SETTLED" in msg and "0xbeef" in msg + + def test_spec_name_wins_when_both_present(self): + headers = httpx.Headers( + {SPEC_NAME: _settlement_header("0xspec"), LEGACY_NAME: _settlement_header("0xlegacy")} + ) + assert "0xspec" in paid_request_error_prefix(headers) + + def test_reader_never_raises_on_a_junk_mapping(self): + class Hostile: + def get(self, _name): + raise RuntimeError("headers exploded") + + assert read_settlement_header(Hostile()) is None + + class TestUnsettled: - """No settlement header = the ordinary shape of a failure that cost nothing.""" + """No settlement header means UNKNOWN, and must never be sold as "free".""" def test_no_header_does_not_claim_payment_was_taken(self): msg = paid_request_error_prefix(httpx.Headers({})) - assert "no settlement recorded" in msg assert "SETTLED" not in msg # The specific phrase that caused the false alarm must be gone. assert "after payment" not in msg + def test_no_header_does_not_claim_payment_was_NOT_taken(self): + """The inverse error, and the more expensive one. + + Solana settles in parallel and re-raises before settle lands, so a + charged-but-failed request carries no header. Promising "payment likely + not taken" there is a false all-clear on exactly the request that needs + a manual refund. + """ + msg = paid_request_error_prefix(httpx.Headers({})) + assert "likely not taken" not in msg + assert "check your wallet history" in msg, "must point somewhere authoritative" + + @pytest.mark.parametrize("name", BOTH_NAMES) @pytest.mark.parametrize( "bad", ["", "!!!not-base64!!!", "e30=", base64.b64encode(b"[]").decode()] ) - def test_unparseable_header_degrades_to_unsettled(self, bad): + def test_unparseable_header_degrades_to_unsettled(self, name, bad): """An error path must never raise while reporting an error.""" - msg = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": bad})) - assert "no settlement recorded" in msg + msg = paid_request_error_prefix(httpx.Headers({name: bad})) + assert "no settlement reported" in msg - def test_header_without_tx_hash_is_not_a_settlement(self): + @pytest.mark.parametrize("name", BOTH_NAMES) + def test_header_without_tx_hash_is_not_a_settlement(self, name): payload = base64.b64encode(json.dumps({"network": "base"}).encode()).decode() - msg = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": payload})) - assert "no settlement recorded" in msg + msg = paid_request_error_prefix(httpx.Headers({name: payload})) + assert "no settlement reported" in msg + + def test_success_true_without_tx_hash_is_still_unsettled(self): + """`success` is not a settle signal: the gateways hard-code it to true + even when settle didn't land, so older clients don't surface an error. + A tx hash is the only field that means money moved — which is what the + gateways gate their own revenue accounting on.""" + payload = base64.b64encode(json.dumps({"success": True, "network": "base"}).encode()) + msg = paid_request_error_prefix(httpx.Headers({SPEC_NAME: payload.decode()})) + assert "SETTLED" not in msg class TestSettled: @@ -52,19 +118,20 @@ class TestSettled: def test_reports_settlement_and_tx_hash(self): msg = paid_request_error_prefix( - httpx.Headers({"X-PAYMENT-RESPONSE": _settlement_header("0xdeadbeef")}) + httpx.Headers({SPEC_NAME: _settlement_header("0xdeadbeef")}) ) assert "SETTLED" in msg assert "0xdeadbeef" in msg, "the tx hash is what makes this actionable" - def test_solana_signature_field_also_counts(self): + @pytest.mark.parametrize("name", BOTH_NAMES) + def test_solana_signature_field_also_counts(self, name): payload = base64.b64encode(json.dumps({"signature": "5xSolSig"}).encode()).decode() - msg = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": payload})) + msg = paid_request_error_prefix(httpx.Headers({name: payload})) assert "SETTLED" in msg and "5xSolSig" in msg def test_the_two_cases_are_distinguishable(): """The whole point: they used to be the same string.""" unsettled = paid_request_error_prefix(httpx.Headers({})) - settled = paid_request_error_prefix(httpx.Headers({"X-PAYMENT-RESPONSE": _settlement_header()})) + settled = paid_request_error_prefix(httpx.Headers({SPEC_NAME: _settlement_header()})) assert unsettled != settled