diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9e2c2..8eacdf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to blockrun-llm will be documented in this file. +## 1.17.1 — 2026-09-30 + +### Fixed +- **Solana images settle at POST, and the SDK now accounts for it.** A slow + model (gpt-image-2, dall-e-3, 4K nano-banana-pro) answers 202 + `poll_url`. + On Solana the gateway settles that payment at submit — a signed transaction + dies with its ~60-90s blockhash, so it cannot wait for the render — and the + poll only delivers. `SolanaLLMClient.image()` / `image_edit()` (sync and + async) assumed Base semantics: + - **Spend was booked on completion**, so a job that failed or timed out after + the 202 was charged on-chain but missing from session spend. It is now + booked at submit, once. + - **Errors said "no payment was taken"** on a timeout. They now say the + payment was settled at submit, and a failed job says so too. + - **Polls replayed the POST signature.** The gateway verifies each poll's + signature to bind the payer, so once the blockhash aged out a slow render + 402'd after it had been paid for. Image polls now re-sign on the same + cadence as video (never charged again — the poll does not settle). + + Solana **video** is unchanged: it settles on the completed poll, and a failed + video job is not charged. +- Free tier: the delisted `nemotron-3-nano-30b` is replaced and the live free + lineup documented (#73). + ## 1.17.0 — 2026-09-16 ### Added diff --git a/VERSION b/VERSION index 092afa1..511a76e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.17.0 +1.17.1 diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index db25c43..c6e8179 100644 --- a/blockrun_llm/__init__.py +++ b/blockrun_llm/__init__.py @@ -196,7 +196,7 @@ create_wallet as generate_wallet, # User-friendly alias ) -__version__ = "1.17.0" +__version__ = "1.17.1" __all__ = [ "DEFAULT_API_KEY_URL", "ENV_API_KEY", diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 73a1126..32517f5 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -549,10 +549,12 @@ class SolanaLLMClient: # Image generation slow-path polling. Models like ``openai/gpt-image-2`` # or ``openai/dall-e-3`` routinely exceed the gateway's 30s inline window - # and come back as 202 + ``poll_url`` instead of the finished image. The - # SDK replays the same PAYMENT-SIGNATURE on every poll; settlement only - # happens on the first completed poll, so a poll-loop timeout = zero - # spend. Budget is conservative — most upstreams finish in 1-3 min. + # and come back as 202 + ``poll_url`` instead of the finished image. Unlike + # video, Solana image routes settle at POST (the signed transaction expires + # with its blockhash long before a slow render ends), so the poll only + # delivers: a timeout or upstream failure after the 202 has already been + # charged. Polls re-sign on the media cadence, since the gateway verifies + # each poll's signature to bind the payer. Most upstreams finish in 1-3 min. IMAGE_POLL_INTERVAL_SECONDS = 5.0 IMAGE_POLL_BUDGET_SECONDS = 300.0 @@ -1972,12 +1974,14 @@ def _request_image_with_payment( poll_interval_seconds: float | None = None, max_resigns: int = 0, label: str = "Image", + settled_at_submit: bool = False, ) -> dict[str, Any]: """Sign + submit + poll wrapper for async media generation. - Shared by :meth:`image` (5-min budget, no mid-poll re-signing needed) - and :meth:`video` (15-min budget, ``max_resigns`` re-signs to survive - the 600s x402 authorization window). ``poll_budget_seconds`` / + Shared by :meth:`image` (5-min budget, ``settled_at_submit`` — Solana + image routes settle at POST) and :meth:`video` (15-min budget, settles + on the completed poll). Both re-sign mid-poll so a signature never + outlives its blockhash. ``poll_budget_seconds`` / ``poll_interval_seconds`` default to the image constants; ``label`` only tunes error text. @@ -2112,6 +2116,14 @@ def _request_image_with_payment( {"response": submit_data}, ) poll_url = self._absolute_url(poll_url_rel) + if settled_at_submit: + # Solana image routes settle at POST (a signed transaction dies with + # its ~60-90s blockhash, so the gateway cannot wait for a long + # render). The charge has already happened: book it now, or a job + # that later fails or times out drops out of session accounting. + self._session_calls += 1 + self._session_total_usd += cost_usd + self._last_call_cost = cost_usd poll_headers = { "User-Agent": _get_user_agent(), "PAYMENT-SIGNATURE": encoded_payment, @@ -2213,7 +2225,8 @@ def _request_image_with_payment( if last_status == "failed": raise APIError( - f"{label} failed upstream: {poll_data.get('error', 'unknown')}", + f"{label} failed upstream: {poll_data.get('error', 'unknown')}" + + (" (payment was settled at submit)" if settled_at_submit else ""), poll_resp.status_code, sanitize_error_response(poll_data if isinstance(poll_data, dict) else {}), retry_after=retry_after_of(poll_resp), @@ -2229,9 +2242,10 @@ def _request_image_with_payment( ) if tx_hash and isinstance(poll_data, dict) and not poll_data.get("txHash"): poll_data["txHash"] = tx_hash - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd + if not settled_at_submit: + self._session_calls += 1 + self._session_total_usd += cost_usd + self._last_call_cost = cost_usd self._capture_settlement(poll_resp) save_to_cache(endpoint, body, poll_data, cost_usd=cost_usd, **self._billing_meta()) self._log_transaction(endpoint, body, poll_data, cost_usd) @@ -2256,11 +2270,20 @@ def _request_image_with_payment( raise APIError( ( - f"{label} did not complete within {budget:.0f}s " - f"(last status: {last_status}). Settlement only happens on " - "completion, so no payment was taken. The job stays claimable " - "for ~48h — re-poll poll_url with a fresh signature from the " - "same wallet to fetch (and settle) the finished result." + ( + f"{label} did not complete within {budget:.0f}s " + f"(last status: {last_status}). Payment was settled at submit; " + "the job stays claimable for ~48h — re-poll poll_url with a " + "fresh signature from the same wallet to fetch the result." + ) + if settled_at_submit + else ( + f"{label} did not complete within {budget:.0f}s " + f"(last status: {last_status}). Settlement only happens on " + "completion, so no payment was taken. The job stays claimable " + "for ~48h — re-poll poll_url with a fresh signature from the " + "same wallet to fetch (and settle) the finished result." + ) ), 504, {"id": job_id, "last_status": last_status, "poll_url": poll_url}, @@ -2285,10 +2308,11 @@ def image( ``xai/grok-imagine-image-pro``, ``black-forest/flux-1.1-pro``. Slow models (gpt-image-2, dall-e-3) trigger the gateway's async - 202 + poll flow; the client polls transparently until completion - and only settles on the final completed poll. If the poll budget - (``IMAGE_POLL_BUDGET_SECONDS``, 5 min) is exhausted, an - :class:`APIError` 504 is raised and **no payment is taken**. + 202 + poll flow; the client polls transparently until completion. + On Solana the payment settles at submit, so if the poll budget + (``IMAGE_POLL_BUDGET_SECONDS``, 5 min) is exhausted the + :class:`APIError` 504 comes **after** the charge — the job stays + claimable for ~48h at its ``poll_url``. Args: quality: ``low`` / ``medium`` / ``high`` / ``auto`` — latency vs @@ -2309,7 +2333,13 @@ def image( validate_image_quality(quality) if quality is not None: body["quality"] = quality - data = self._request_image_with_payment("/v1/images/generations", body, timeout=timeout) + data = self._request_image_with_payment( + "/v1/images/generations", + body, + timeout=timeout, + max_resigns=self.MEDIA_POLL_MAX_RESIGNS, + settled_at_submit=True, + ) return ImageResponse(**data) def image_edit( @@ -2351,7 +2381,13 @@ def image_edit( if quality is not None: body["quality"] = quality - data = self._request_image_with_payment("/v1/images/image2image", body, timeout=timeout) + data = self._request_image_with_payment( + "/v1/images/image2image", + body, + timeout=timeout, + max_resigns=self.MEDIA_POLL_MAX_RESIGNS, + settled_at_submit=True, + ) return ImageResponse(**data) # ------------------------------------------------------------------ @@ -4485,7 +4521,11 @@ async def image( if quality is not None: body["quality"] = quality data = await self._request_image_with_payment( - "/v1/images/generations", body, timeout=timeout + "/v1/images/generations", + body, + timeout=timeout, + max_resigns=SolanaLLMClient.MEDIA_POLL_MAX_RESIGNS, + settled_at_submit=True, ) return ImageResponse(**data) @@ -4527,7 +4567,11 @@ async def image_edit( body["quality"] = quality data = await self._request_image_with_payment( - "/v1/images/image2image", body, timeout=timeout + "/v1/images/image2image", + body, + timeout=timeout, + max_resigns=SolanaLLMClient.MEDIA_POLL_MAX_RESIGNS, + settled_at_submit=True, ) return ImageResponse(**data) @@ -5008,6 +5052,7 @@ async def _request_image_with_payment( poll_interval_seconds: float | None = None, max_resigns: int = 0, label: str = "Image", + settled_at_submit: bool = False, ) -> dict[str, Any]: """Async sign + submit + poll wrapper for async media generation — the async mirror of the sync :class:`SolanaLLMClient` helper. Shared by @@ -5122,6 +5167,14 @@ async def _request_image_with_payment( if not poll_url_rel: raise APIError("Slow-path 202 missing poll_url", 202, {"response": submit_data}) poll_url = self._absolute_url(poll_url_rel) + if settled_at_submit: + # Solana image routes settle at POST (a signed transaction dies with + # its ~60-90s blockhash, so the gateway cannot wait for a long + # render). The charge has already happened: book it now, or a job + # that later fails or times out drops out of session accounting. + self._session_calls += 1 + self._session_total_usd += cost_usd + self._last_call_cost = cost_usd poll_headers = { "User-Agent": _get_user_agent(), "PAYMENT-SIGNATURE": encoded_payment, @@ -5215,7 +5268,8 @@ async def _request_image_with_payment( if last_status == "failed": raise APIError( - f"{label} failed upstream: {poll_data.get('error', 'unknown')}", + f"{label} failed upstream: {poll_data.get('error', 'unknown')}" + + (" (payment was settled at submit)" if settled_at_submit else ""), poll_resp.status_code, sanitize_error_response(poll_data if isinstance(poll_data, dict) else {}), retry_after=retry_after_of(poll_resp), @@ -5229,9 +5283,10 @@ async def _request_image_with_payment( ) if tx_hash and isinstance(poll_data, dict) and not poll_data.get("txHash"): poll_data["txHash"] = tx_hash - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd + if not settled_at_submit: + self._session_calls += 1 + self._session_total_usd += cost_usd + self._last_call_cost = cost_usd self._capture_settlement(poll_resp) save_to_cache(endpoint, body, poll_data, cost_usd=cost_usd, **self._billing_meta()) self._log_transaction(endpoint, body, poll_data, cost_usd) @@ -5254,11 +5309,20 @@ async def _request_image_with_payment( raise APIError( ( - f"{label} did not complete within {budget:.0f}s " - f"(last status: {last_status}). Settlement only happens on " - "completion, so no payment was taken. The job stays claimable " - "for ~48h — re-poll poll_url with a fresh signature from the " - "same wallet to fetch (and settle) the finished result." + ( + f"{label} did not complete within {budget:.0f}s " + f"(last status: {last_status}). Payment was settled at submit; " + "the job stays claimable for ~48h — re-poll poll_url with a " + "fresh signature from the same wallet to fetch the result." + ) + if settled_at_submit + else ( + f"{label} did not complete within {budget:.0f}s " + f"(last status: {last_status}). Settlement only happens on " + "completion, so no payment was taken. The job stays claimable " + "for ~48h — re-poll poll_url with a fresh signature from the " + "same wallet to fetch (and settle) the finished result." + ) ), 504, {"id": job_id, "last_status": last_status, "poll_url": poll_url}, diff --git a/pyproject.toml b/pyproject.toml index 5014282..9704fd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.17.0" +version = "1.17.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_solana_media.py b/tests/unit/test_solana_media.py index 4d618a5..fadcb3b 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -724,3 +724,78 @@ async def test_async_image_rejects_unknown_quality_before_paying(self) -> None: with pytest.raises(ValueError, match="quality must be one of"): await client.image("a cat", quality="hd") assert calls == [] + + +# --------------------------------------------------------------------------- +# Solana image routes settle at POST (unlike video). The 202 has already been +# charged, so spend is booked at submit, a failed/timed-out job must not claim +# "no payment was taken", and polls must re-sign — the gateway verifies each +# poll's signature to bind the payer, and a slow render outlives the blockhash. +# --------------------------------------------------------------------------- + + +def _image_handler(signed_polls: list[dict[str, Any]]): + """probe → 402; signed POST → 202; each signed GET poll returns the next + ``{"code", "json"}``; an unsigned GET is the re-challenge (402).""" + pr = {"content-type": "application/json", "payment-required": "stub"} + state = {"i": 0} + + def handler(request: httpx.Request) -> httpx.Response: + has_sig = "PAYMENT-SIGNATURE" in request.headers + if request.method == "POST": + if not has_sig: + return httpx.Response(402, headers=pr, json={"error": "Payment Required"}) + return httpx.Response( + 202, + json={ + "id": "IMG", + "poll_url": "/api/v1/images/generations/IMG", + "status": "queued", + }, + ) + if not has_sig: + return httpx.Response(402, headers=pr, json={"error": "Payment Required"}) + step = signed_polls[min(state["i"], len(signed_polls) - 1)] + state["i"] += 1 + return httpx.Response( + step["code"], json=step["json"], headers=pr if step["code"] == 402 else {} + ) + + return handler + + +_STALE = {"code": 402, "json": {"error": "Payment verification failed"}} + + +class TestSolanaImageSettlesAtPost: + @pytest.fixture(autouse=True) + def _fast_polls(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SolanaLLMClient, "IMAGE_POLL_INTERVAL_SECONDS", 0.001) + + def test_failed_job_is_booked_and_says_charged(self) -> None: + failed = {"code": 200, "json": {"status": "failed", "error": "content policy"}} + client = _make_client(_image_handler([_STALE, failed])) + with pytest.raises(APIError, match="settled at submit"): + client.image("a cat", model="openai/gpt-image-2") + assert client._session_calls == 1 + assert client._session_total_usd == pytest.approx(1.0) + + def test_stale_poll_resigns_and_completes_without_double_booking(self) -> None: + done = { + "code": 200, + "json": {"status": "completed", "created": 1, "data": [{"url": "https://cdn/i.png"}]}, + } + client = _make_client(_image_handler([_STALE, done])) + resp = client.image("a cat", model="openai/gpt-image-2") + assert resp.data[0].url == "https://cdn/i.png" + assert client._session_calls == 1 + + async def test_async_failed_job_is_booked(self) -> None: + failed = {"code": 200, "json": {"status": "failed", "error": "content policy"}} + client = _make_async_client(_image_handler([_STALE, failed])) + try: + with pytest.raises(APIError, match="settled at submit"): + await client.image("a cat", model="openai/gpt-image-2") + assert client._session_calls == 1 + finally: + await client._client.aclose()