From 043f7be922ddc5d3e6363bfd2eda37ed504a1af3 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Tue, 21 Apr 2026 00:50:38 -0400 Subject: [PATCH 1/2] surface x402 payment-required details on LLM HTTP errors Decodes the base64-encoded `payment-required` response header so chat failures report the actual x402 error (e.g. permit2_simulation_failed) instead of a bare status line. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/opengradient/client/llm.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/opengradient/client/llm.py b/src/opengradient/client/llm.py index 2d03512..bb7e125 100644 --- a/src/opengradient/client/llm.py +++ b/src/opengradient/client/llm.py @@ -1,5 +1,6 @@ """LLM chat and completion via TEE-verified execution with x402 payments.""" +import base64 import json import logging from dataclasses import dataclass @@ -35,6 +36,7 @@ _COMPLETION_ENDPOINT = "/v1/completions" _REQUEST_TIMEOUT = 60 + @dataclass(frozen=True) class _ChatParams: """Bundles the common parameters for chat/completion requests.""" @@ -385,8 +387,9 @@ async def _request() -> TextGenerationOutput: headers=self._headers(params.x402_settlement_mode), timeout=_REQUEST_TIMEOUT, ) - response.raise_for_status() raw_body = await response.aread() + if response.is_error: + raise RuntimeError(_format_http_error(response, raw_body)) result = json.loads(raw_body.decode()) choices = result.get("choices") @@ -532,3 +535,23 @@ async def _parse_sse_response(self, response, tee) -> AsyncGenerator[StreamChunk chunk.tee_endpoint = tee.endpoint chunk.tee_payment_address = tee.payment_address yield chunk + + +def _decode_payment_required(header_value: Optional[str]) -> str: + """Decode the base64-encoded JSON in the `payment-required` response header.""" + if not header_value: + return "" + try: + decoded = base64.b64decode(header_value).decode("utf-8") + return json.dumps(json.loads(decoded), indent=2) + except Exception: + return header_value + + +def _format_http_error(response: httpx.Response, body: bytes) -> str: + """Build an error message that surfaces the x402 payment-required details.""" + return ( + f"HTTP {response.status_code} from {response.url}\n" + f"Payment-Required: {_decode_payment_required(response.headers.get('payment-required'))}\n" + f"Body: {body.decode(errors='replace')}" + ) From 5187733f3c08108c51e55896dd9cd2e843089f39 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Tue, 21 Apr 2026 00:57:03 -0400 Subject: [PATCH 2/2] raise HTTPStatusError so retry path skips, and keep tests green Uses status_code gate (not is_error) and wraps the decoded x402 detail in httpx.HTTPStatusError so _call_with_tee_retry correctly skips retries on server-side failures. Adds request/url/headers to the test fake to match the httpx Response contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/opengradient/client/llm.py | 8 ++++++-- tests/llm_test.py | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/opengradient/client/llm.py b/src/opengradient/client/llm.py index bb7e125..e7e5337 100644 --- a/src/opengradient/client/llm.py +++ b/src/opengradient/client/llm.py @@ -388,8 +388,12 @@ async def _request() -> TextGenerationOutput: timeout=_REQUEST_TIMEOUT, ) raw_body = await response.aread() - if response.is_error: - raise RuntimeError(_format_http_error(response, raw_body)) + if response.status_code >= 400: + raise httpx.HTTPStatusError( + _format_http_error(response, raw_body), + request=response.request, + response=response, + ) result = json.loads(raw_body.decode()) choices = result.get("choices") diff --git a/tests/llm_test.py b/tests/llm_test.py index 8e5aba2..5309f28 100644 --- a/tests/llm_test.py +++ b/tests/llm_test.py @@ -7,7 +7,7 @@ import json import ssl from contextlib import asynccontextmanager -from typing import List +from typing import Dict, List from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -80,6 +80,9 @@ class _FakeResponse: def __init__(self, status_code: int, body: bytes): self.status_code = status_code self._body = body + self.headers: Dict[str, str] = {} + self.request = MagicMock() + self.url = "https://test.tee.server/v1/chat/completions" def raise_for_status(self): pass