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
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,59 @@

All notable changes to blockrun-llm will be documented in this file.

## 1.7.0 — 2026-07-15

### Added
- **`input_type` on video generation** (`VideoClient.generate`, `SolanaLLMClient.video`,
`AsyncSolanaLLMClient.video`). Declares the intended seed mode — `text` /
`image` / `first_last_frame` / `reference`. The gateway infers the mode from
the seed fields and rejects with 400 **before charging** when the declared
value disagrees, turning an expensive silent failure into a loud one: a
dropped `image_url` otherwise yields a text-to-video clip you still pay for.
Accepted on both chains.
- **`quality` on Solana image generation + editing** (`SolanaLLMClient.image` /
`image_edit`, sync and async). `low` / `medium` / `high` / `auto` for
`openai/gpt-image-*`; `low` meaningfully cuts generation time.

**Solana only, by design.** The Base gateway defines no `quality` field and
strips unknown keys, so a value sent there would be silently dropped —
`ImageClient.generate`/`edit` therefore keep rejecting it, now with a hint
pointing at the Solana client.

### Notes
- Reference-to-video (`reference_videos` / `reference_audios`) is **not** exposed.
Both gateways gate it behind `R2V_ENABLED`, which is currently off, so every
call would return 503. It slots in once that flips.
- Validation covers spelling only. Whether a declared mode matches the seed
fields, and which models accept `quality`, stay the gateway's call — it
answers both before billing, so a second copy here would only drift.

## 1.6.1 — 2026-07-15

### Fixed
- Fail fast when the payer has no USDC token account (#23). Below this an
unfunded wallet burned all 5 payment retries, each costing the gateway 4
verify retries — 20 facilitator calls per doomed request.

## 1.6.0 — 2026-07-08

### Added
- Attach the BlockRun builder-code service code to Base-chain x402 payments (#21).

## 1.5.1 — 2026-07-08

### Fixed
- Keep Solana video settlement blockhash fresh via proactive re-sign (#22).
Seedance 2.0 jobs could run long enough to exhaust the older two-retry
settlement loop and surface `transaction_simulation_failed`.

## 1.5.0 — 2026-07-06

### Added
- Solana media surface: video / music / speech / portrait / realface / price /
rpc (#16), plus the `rpc_batch` cache fix (#17), a `solana<0.40` pin (#18),
and media hardening (#19).

## 1.4.7 — 2026-06-26

### Added
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,19 @@ automatically.

Image editing (`client.edit` / `client.image_edit`) hits the `/v1/images/image2image` endpoint and supports `openai/gpt-image-1`, `openai/gpt-image-2`, `google/nano-banana`, and `google/nano-banana-pro`. Pass a list of source images to fuse multiple inputs (openai/* up to 4, google/* up to 3).

**`quality` (Solana only).** On Solana, `image` and `image_edit` accept
`quality="low" | "medium" | "high" | "auto"` for `openai/gpt-image-*` — `low`
meaningfully cuts generation time:

```python
sol = SolanaLLMClient()
result = sol.image("a red apple", model="openai/gpt-image-2", quality="low")
```

This is deliberately absent from the Base `ImageClient`: the Base gateway has
no `quality` field and would silently ignore the value, so passing it there
raises `TypeError` rather than quietly doing nothing.

### Video Generation
| Model | Price | Default 5s 720p |
|-------|-------|-----------------|
Expand Down Expand Up @@ -480,6 +493,20 @@ result = client.generate(
"https://example.com/city.jpg",
],
)

# input_type — declare the seed mode you intend, and get an error instead of
# a surprise. The gateway infers the mode from the seed fields above; if your
# declared value disagrees it returns 400 WITHOUT charging.
#
# Worth it when the seed fields are built dynamically: if `image_url` comes
# back empty, the request quietly degrades to text-to-video and you still pay
# for the clip. Declaring input_type="image" turns that into a 400 instead.
result = client.generate(
"the portrait turns to face the camera",
model="bytedance/seedance-2.0",
image_url=maybe_empty_url, # if this is falsy...
input_type="image", # ...you get a 400, not a text-to-video bill
)
```

### Text-to-Speech & Sound Effects (`SpeechClient`)
Expand Down
2 changes: 1 addition & 1 deletion blockrun_llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
)
from .tx_log import TransactionLogger, decode_settlement_header, format_row

__version__ = "1.6.1"
__version__ = "1.7.0"
__all__ = [
"LLMClient",
"AsyncLLMClient",
Expand Down
16 changes: 14 additions & 2 deletions blockrun_llm/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,15 @@ def generate(
"""
if kwargs:
unsupported = ", ".join(sorted(kwargs.keys()))
hint = (
" `quality` is Solana-only (SolanaLLMClient.image) — the Base gateway "
"has no such field and would silently ignore it."
if "quality" in kwargs
else ""
)
raise TypeError(
f"generate() got unexpected keyword argument(s): {unsupported}. "
f"Valid parameters are: prompt, model, size, n"
f"Valid parameters are: prompt, model, size, n.{hint}"
)

# Build request body
Expand Down Expand Up @@ -223,9 +229,15 @@ def edit(
"""
if kwargs:
unsupported = ", ".join(sorted(kwargs.keys()))
hint = (
" `quality` is Solana-only (SolanaLLMClient.image_edit) — the Base "
"gateway has no such field and would silently ignore it."
if "quality" in kwargs
else ""
)
raise TypeError(
f"edit() got unexpected keyword argument(s): {unsupported}. "
f"Valid parameters are: prompt, image, model, mask, size, n"
f"Valid parameters are: prompt, image, model, mask, size, n.{hint}"
)

body: Dict[str, Any] = {
Expand Down
69 changes: 68 additions & 1 deletion blockrun_llm/solana_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
build_payment_rejected_error,
sanitize_error_response,
validate_api_url,
validate_image_quality,
validate_video_input_type,
)

try:
Expand Down Expand Up @@ -1741,6 +1743,7 @@ def image(
model: str = "google/nano-banana",
size: str = "1024x1024",
n: int = 1,
quality: Optional[str] = None,
timeout: Optional[float] = None,
) -> ImageResponse:
"""Generate an image from a text prompt (Solana payment).
Expand All @@ -1756,13 +1759,26 @@ def image(
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**.

Args:
quality: ``low`` / ``medium`` / ``high`` / ``auto`` — latency vs
fidelity, ``openai/gpt-image-*`` only. ``low`` meaningfully
cuts generation time. Solana only: the Base gateway has no
such field, so ``ImageClient`` deliberately omits it rather
than accept a value that would be silently dropped.

Raises:
ValueError: If ``quality`` is not one of the four accepted values.
"""
body: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"size": size,
"n": n,
}
validate_image_quality(quality)
if quality is not None:
body["quality"] = quality
data = self._request_image_with_payment("/v1/images/generations", body, timeout=timeout)
return ImageResponse(**data)

Expand All @@ -1775,6 +1791,7 @@ def image_edit(
mask: Optional[str] = None,
size: str = "1024x1024",
n: int = 1,
quality: Optional[str] = None,
timeout: Optional[float] = None,
) -> ImageResponse:
"""Edit an image using img2img (Solana payment). ``image`` may be a
Expand All @@ -1783,6 +1800,13 @@ def image_edit(

Like :meth:`image`, this handles the gateway's async 202 + poll
slow path transparently — settlement only happens on completion.

Args:
quality: ``low`` / ``medium`` / ``high`` / ``auto``, as in
:meth:`image` — ``openai/gpt-image-*`` only, Solana only.

Raises:
ValueError: If ``quality`` is not one of the four accepted values.
"""
body: Dict[str, Any] = {
"model": model,
Expand All @@ -1793,6 +1817,9 @@ def image_edit(
}
if mask is not None:
body["mask"] = mask
validate_image_quality(quality)
if quality is not None:
body["quality"] = quality

data = self._request_image_with_payment("/v1/images/image2image", body, timeout=timeout)
return ImageResponse(**data)
Expand All @@ -1817,6 +1844,7 @@ def video(
seed: Optional[int] = None,
watermark: Optional[bool] = None,
return_last_frame: Optional[bool] = None,
input_type: Optional[str] = None,
budget_seconds: Optional[float] = None,
timeout: Optional[float] = None,
) -> VideoResponse:
Expand All @@ -1827,6 +1855,13 @@ def video(
happens on the first completed poll, so a poll-budget timeout takes
**no payment** and leaves the job claimable ~48h. Default model is
``xai/grok-imagine-video``.

Args:
input_type: Optional assertion of the seed mode — ``text`` /
``image`` / ``first_last_frame`` / ``reference``. The gateway
rejects (400, unbilled) if it disagrees with the seed fields
sent, turning a silent wrong-mode clip into an error. See
``VideoClient.generate``.
"""
body = self._build_video_body(
prompt,
Expand All @@ -1842,6 +1877,7 @@ def video(
seed=seed,
watermark=watermark,
return_last_frame=return_last_frame,
input_type=input_type,
)

data = self._request_image_with_payment(
Expand Down Expand Up @@ -2173,9 +2209,13 @@ def _build_video_body(
seed: Optional[int],
watermark: Optional[bool],
return_last_frame: Optional[bool],
input_type: Optional[str],
) -> Dict[str, Any]:
"""Validate video kwargs and build the request body. Shared by the sync
and async ``video()`` so their validation and payload never drift."""
and async ``video()`` so their validation and payload never drift.

Every param is required (pass None to omit) precisely so a caller can't
silently drop one — the drift this builder exists to prevent."""
if image_url and real_face_asset_id:
raise ValueError(
"image_url and real_face_asset_id are mutually exclusive; pass at most one."
Expand Down Expand Up @@ -2203,6 +2243,7 @@ def _build_video_body(
"real_face_asset_id must start with 'ta_' "
"(a Virtual Portrait or RealFace asset id, e.g. 'ta_abc123xyz')"
)
validate_video_input_type(input_type)

body: Dict[str, Any] = {
"model": model or SolanaLLMClient.VIDEO_DEFAULT_MODEL,
Expand Down Expand Up @@ -2230,6 +2271,8 @@ def _build_video_body(
body["watermark"] = watermark
if return_last_frame is not None:
body["return_last_frame"] = return_last_frame
if input_type is not None:
body["input_type"] = input_type
return body

@staticmethod
Expand Down Expand Up @@ -3522,6 +3565,7 @@ async def image(
model: str = "google/nano-banana",
size: str = "1024x1024",
n: int = 1,
quality: Optional[str] = None,
timeout: Optional[float] = None,
) -> ImageResponse:
"""Generate an image from a text prompt (Solana payment).
Expand All @@ -3531,13 +3575,23 @@ async def image(
completion and only settles on the final completed poll. If the poll
budget is exhausted an :class:`APIError` 504 is raised and **no payment
is taken**.

Args:
quality: ``low`` / ``medium`` / ``high`` / ``auto``,
``openai/gpt-image-*`` only. See :meth:`SolanaLLMClient.image`.

Raises:
ValueError: If ``quality`` is not one of the four accepted values.
"""
body: Dict[str, Any] = {
"model": model,
"prompt": prompt,
"size": size,
"n": n,
}
validate_image_quality(quality)
if quality is not None:
body["quality"] = quality
data = await self._request_image_with_payment(
"/v1/images/generations", body, timeout=timeout
)
Expand All @@ -3552,12 +3606,20 @@ async def image_edit(
mask: Optional[str] = None,
size: str = "1024x1024",
n: int = 1,
quality: Optional[str] = None,
timeout: Optional[float] = None,
) -> ImageResponse:
"""Edit an image using img2img (Solana payment). ``image`` may be a
single data URI or a list of 1-4 data URIs for multi-image fusion
(openai/* up to 4, google/* up to 3). Handles the async 202 + poll
slow path transparently — settlement only happens on completion.

Args:
quality: ``low`` / ``medium`` / ``high`` / ``auto``,
``openai/gpt-image-*`` only. See :meth:`SolanaLLMClient.image`.

Raises:
ValueError: If ``quality`` is not one of the four accepted values.
"""
body: Dict[str, Any] = {
"model": model,
Expand All @@ -3568,6 +3630,9 @@ async def image_edit(
}
if mask is not None:
body["mask"] = mask
validate_image_quality(quality)
if quality is not None:
body["quality"] = quality

data = await self._request_image_with_payment(
"/v1/images/image2image", body, timeout=timeout
Expand Down Expand Up @@ -3613,6 +3678,7 @@ async def video(
seed: Optional[int] = None,
watermark: Optional[bool] = None,
return_last_frame: Optional[bool] = None,
input_type: Optional[str] = None,
budget_seconds: Optional[float] = None,
timeout: Optional[float] = None,
) -> VideoResponse:
Expand All @@ -3632,6 +3698,7 @@ async def video(
seed=seed,
watermark=watermark,
return_last_frame=return_last_frame,
input_type=input_type,
)

data = await self._request_image_with_payment(
Expand Down
Loading
Loading