Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.17.0
1.17.1
2 changes: 1 addition & 1 deletion blockrun_llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
130 changes: 97 additions & 33 deletions blockrun_llm/solana_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -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},
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/test_solana_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading