From c8aa20d477c536ab562f4fae4272038a627d9e5d Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 15 Jul 2026 16:37:03 -0500 Subject: [PATCH 1/4] feat(media): forward video input_type and Solana image quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both params are accepted by the gateway today but had no way through the SDK, so callers could not reach them — and blockrun-litellm#18 tried to forward them anyway, producing a TypeError against every published release. input_type (VideoClient.generate + SolanaLLMClient.video, sync and async): declares the intended seed mode. The gateway infers the mode from the seed fields and 400s — before charging — when the declared value disagrees. That converts an expensive silent failure into an error: a dropped image_url otherwise yields a text-to-video clip the caller still pays for. quality (SolanaLLMClient.image/image_edit, sync and async): low/medium/high/ auto latency-vs-fidelity for openai/gpt-image-*. Solana only, deliberately. The Base gateway defines no such field and zod strips unknown keys, so a quality sent to Base would be silently dropped — ImageClient already rejects it on purpose (test_image_parameter_validation) and keeps doing so. Validation lives in validation.py alongside the other validate_* helpers and covers spelling only. Whether a declared mode matches the seed fields, and which models accept quality, stay the gateway's call — it already answers both before billing, so a second copy here would only drift. The enums are verified equal to the gateway zod enums on both chains. Reference-to-video (reference_videos/reference_audios) is deliberately NOT added: both gateways gate it behind R2V_ENABLED, which is unset on blockrun-sol and "false" on blockrun-web, so every call returns 503 until that flips. --- blockrun_llm/solana_client.py | 64 +++++++++++++++++ blockrun_llm/validation.py | 60 ++++++++++++++++ blockrun_llm/video.py | 17 ++++- tests/unit/test_solana_media.py | 121 ++++++++++++++++++++++++++++++++ tests/unit/test_video_params.py | 43 ++++++++++++ 5 files changed, 304 insertions(+), 1 deletion(-) diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index a9845bc..e886b8d 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -60,6 +60,8 @@ build_payment_rejected_error, sanitize_error_response, validate_api_url, + validate_image_quality, + validate_video_input_type, ) try: @@ -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). @@ -1756,6 +1759,16 @@ 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, @@ -1763,6 +1776,9 @@ def image( "size": size, "n": n, } + if quality is not None: + validate_image_quality(quality) + body["quality"] = quality data = self._request_image_with_payment("/v1/images/generations", body, timeout=timeout) return ImageResponse(**data) @@ -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 @@ -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, @@ -1793,6 +1817,9 @@ def image_edit( } if mask is not None: body["mask"] = mask + if quality is not None: + validate_image_quality(quality) + body["quality"] = quality data = self._request_image_with_payment("/v1/images/image2image", body, timeout=timeout) return ImageResponse(**data) @@ -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: @@ -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, @@ -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( @@ -2173,6 +2209,7 @@ def _build_video_body( seed: Optional[int], watermark: Optional[bool], return_last_frame: Optional[bool], + input_type: Optional[str] = None, ) -> 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.""" @@ -2203,6 +2240,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, @@ -2230,6 +2268,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 @@ -3522,6 +3562,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). @@ -3531,6 +3572,13 @@ 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, @@ -3538,6 +3586,9 @@ async def image( "size": size, "n": n, } + if quality is not None: + validate_image_quality(quality) + body["quality"] = quality data = await self._request_image_with_payment( "/v1/images/generations", body, timeout=timeout ) @@ -3552,12 +3603,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, @@ -3568,6 +3627,9 @@ async def image_edit( } if mask is not None: body["mask"] = mask + if quality is not None: + validate_image_quality(quality) + body["quality"] = quality data = await self._request_image_with_payment( "/v1/images/image2image", body, timeout=timeout @@ -3613,6 +3675,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: @@ -3632,6 +3695,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( diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py index 6d552ff..8957621 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -36,6 +36,15 @@ "zai", } +# Seed modes a caller may assert via `input_type` on /v1/videos/generations. +# Mirrors the gateway enum; the gateway stays the authority on whether the +# declared mode matches the seed fields actually sent. +VIDEO_INPUT_TYPES = ("text", "image", "first_last_frame", "reference") + +# Latency/fidelity levels for `quality` on Solana image generation + editing. +# Mirrors the gateway enum, which accepts the field for openai/gpt-image-* only. +IMAGE_QUALITY_LEVELS = ("low", "medium", "high", "auto") + # Base58 alphabet characters that never appear in a hex string. Their presence # is a strong signal that a key is a base58-encoded Solana key, not an EVM key. @@ -147,6 +156,57 @@ def validate_model(model: str) -> None: pass +def validate_video_input_type(input_type: Optional[str]) -> None: + """ + Validate the optional `input_type` seed-mode assertion on video generation. + + Only the spelling is checked. Whether the declared mode agrees with the + seed fields actually sent is the gateway's call — it infers the mode and + rejects with 400 *before* charging, so re-deriving that inference here + would add a second copy to keep in sync for no benefit. + + Args: + input_type: One of VIDEO_INPUT_TYPES, or None to leave it unset. + + Raises: + ValueError: If input_type is not one of the accepted values. + + Example: + >>> validate_video_input_type("first_last_frame") + """ + if input_type is None: + return + if input_type not in VIDEO_INPUT_TYPES: + raise ValueError( + f"input_type must be one of {', '.join(VIDEO_INPUT_TYPES)}; got {input_type!r}." + ) + + +def validate_image_quality(quality: Optional[str]) -> None: + """ + Validate the optional `quality` knob on Solana image generation/editing. + + Model compatibility is left to the gateway, which accepts `quality` only + for openai/gpt-image-* and returns a clear error otherwise — encoding that + model list here would go stale every time the catalog changes. + + Args: + quality: One of IMAGE_QUALITY_LEVELS, or None to leave it unset. + + Raises: + ValueError: If quality is not one of the accepted values. + + Example: + >>> validate_image_quality("low") + """ + if quality is None: + return + if quality not in IMAGE_QUALITY_LEVELS: + raise ValueError( + f"quality must be one of {', '.join(IMAGE_QUALITY_LEVELS)}; got {quality!r}." + ) + + def validate_max_tokens(max_tokens: Optional[int]) -> None: """ Validate max_tokens parameter. diff --git a/blockrun_llm/video.py b/blockrun_llm/video.py index 0209888..5f9252b 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -41,6 +41,7 @@ validate_private_key, validate_api_url, sanitize_error_response, + validate_video_input_type, ) load_dotenv() @@ -147,6 +148,7 @@ def generate( seed: Optional[int] = None, watermark: Optional[bool] = None, return_last_frame: Optional[bool] = None, + input_type: Optional[str] = None, budget_seconds: Optional[float] = None, ) -> VideoResponse: """ @@ -190,6 +192,15 @@ def generate( watermark: Add the provider watermark (Seedance only). return_last_frame: Also return the final frame as an image (Seedance only). + input_type: Optional assertion of the seed mode you intend — + `text` / `image` / `first_last_frame` / `reference`. Purely a + guard: the gateway infers the mode from the seed fields above + and rejects with 400 (before charging) if your declared value + disagrees. Use it when a caller builds the seed fields + dynamically and a silently-wrong mode would be expensive — a + dropped `image_url` yields a text-to-video clip you still pay + for, whereas declaring `input_type="image"` turns that into an + error. Leave unset to accept whatever the inputs imply. budget_seconds: Overall polling budget (default 900s). Returns: @@ -199,7 +210,8 @@ def generate( Raises: ValueError: If mutually-exclusive image inputs are combined (see above), `last_frame_url` is passed without `image_url`, - or `real_face_asset_id` is malformed. + `real_face_asset_id` is malformed, or `input_type` is not one + of the four accepted values. PaymentError: If wallet balance is insufficient. APIError: If upstream fails, the job times out, or any transport error occurs. @@ -233,6 +245,7 @@ def generate( "enroll via PortraitClient / POST /v1/portrait/enroll or " "RealFaceClient / POST /v1/realface/enroll)" ) + validate_video_input_type(input_type) body: Dict[str, Any] = { "model": model or self.DEFAULT_MODEL, @@ -260,6 +273,8 @@ def generate( 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 budget = ( budget_seconds if budget_seconds is not None else self.DEFAULT_GENERATE_BUDGET_SECONDS diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index 6f16cfb..79ce80d 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -554,3 +554,124 @@ async def test_async_image_path_never_resigns(self, monkeypatch: pytest.MonkeyPa assert len(set(poll_sigs)) == 1, poll_sigs finally: await client._client.aclose() + + +_IMAGE_OK = {"created": 1, "model": "openai/gpt-image-2", "data": [{"url": "https://cdn/x.png"}]} +_VIDEO_OK = {"created": 1, "model": "xai/grok-imagine-video", "data": [{"url": "https://cdn/x.mp4"}]} +_DATA_URI = "data:image/png;base64,AA==" + + +class TestSolanaImageQuality: + """`quality` is a Solana-only latency/fidelity knob (openai/gpt-image-* on + the gateway). The Base gateway has no such field and zod would silently + strip it, which is why ImageClient deliberately rejects it — see + test_image_parameter_validation.test_generate_rejects_quality_parameter. + """ + + def test_image_forwards_quality(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _IMAGE_OK)) + client.image("a cat", model="openai/gpt-image-2", quality="low") + sent = json.loads(calls[-1].content) + assert sent["quality"] == "low" + + def test_image_omits_quality_when_unset(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _IMAGE_OK)) + client.image("a cat") + assert "quality" not in json.loads(calls[-1].content) + + def test_image_edit_forwards_quality(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _IMAGE_OK)) + client.image_edit("make it green", _DATA_URI, quality="high") + sent = json.loads(calls[-1].content) + assert sent["quality"] == "high" + assert calls[-1].url.path == "/api/v1/images/image2image" + + @pytest.mark.parametrize("value", ["low", "medium", "high", "auto"]) + def test_image_accepts_every_gateway_quality(self, value: str) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _IMAGE_OK)) + client.image("a cat", model="openai/gpt-image-2", quality=value) + assert json.loads(calls[-1].content)["quality"] == value + + def test_image_rejects_unknown_quality_before_paying(self) -> None: + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _IMAGE_OK)) + with pytest.raises(ValueError, match="quality must be one of"): + client.image("a cat", model="openai/gpt-image-2", quality="hd") + assert calls == [] # rejected locally — no request, no payment + + def test_image_edit_rejects_unknown_quality_before_paying(self) -> None: + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _IMAGE_OK)) + with pytest.raises(ValueError, match="quality must be one of"): + client.image_edit("make it green", _DATA_URI, quality="ultra") + assert calls == [] + + +class TestSolanaVideoInputType: + def test_video_forwards_input_type(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _VIDEO_OK)) + client.video( + "the flower blooms", + image_url="https://example.com/bud.jpg", + last_frame_url="https://example.com/bloom.jpg", + input_type="first_last_frame", + ) + assert json.loads(calls[-1].content)["input_type"] == "first_last_frame" + + def test_video_omits_input_type_when_unset(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _VIDEO_OK)) + client.video("a calm lake") + assert "input_type" not in json.loads(calls[-1].content) + + def test_video_rejects_unknown_input_type_before_paying(self) -> None: + calls: List[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _VIDEO_OK)) + with pytest.raises(ValueError, match="input_type must be one of"): + client.video("x", input_type="img") + assert calls == [] + + +class TestSharedVideoBodyBuilder: + """Sync and async video() share _build_video_body so they can't drift.""" + + def test_input_type_reaches_body(self) -> None: + body = SolanaLLMClient._build_video_body( + "x", + model=None, + image_url=None, + last_frame_url=None, + reference_image_urls=None, + real_face_asset_id=None, + duration_seconds=None, + aspect_ratio=None, + resolution=None, + generate_audio=None, + seed=None, + watermark=None, + return_last_frame=None, + input_type="text", + ) + assert body["input_type"] == "text" + + def test_async_video_exposes_input_type(self) -> None: + import inspect + + assert "input_type" in inspect.signature(AsyncSolanaLLMClient.video).parameters diff --git a/tests/unit/test_video_params.py b/tests/unit/test_video_params.py index 721a027..b35dcbe 100644 --- a/tests/unit/test_video_params.py +++ b/tests/unit/test_video_params.py @@ -111,3 +111,46 @@ def test_image_url_and_real_face_still_exclusive(client): image_url="https://example.com/a.jpg", real_face_asset_id="ta_abc123", ) + + +# --- input_type ------------------------------------------------------------ +# A declared seed mode the gateway cross-checks against the fields actually +# sent. Only the spelling is validated locally; the match is the gateway's +# call (400, unbilled) so the two can't drift. + + +def test_input_type_forwarded(client, captured): + client.generate( + "the flower blooms", + model="bytedance/seedance-1.5-pro", + image_url="https://example.com/bud.jpg", + last_frame_url="https://example.com/bloom.jpg", + input_type="first_last_frame", + ) + assert captured["body"]["input_type"] == "first_last_frame" + + +def test_input_type_omitted_when_unset(client, captured): + client.generate("a calm lake at dawn") + assert "input_type" not in captured["body"] + + +@pytest.mark.parametrize("value", ["text", "image", "first_last_frame", "reference"]) +def test_input_type_accepts_every_gateway_mode(client, captured, value): + client.generate("x", input_type=value) + assert captured["body"]["input_type"] == value + + +def test_input_type_rejects_unknown_value(client): + with pytest.raises(ValueError, match="input_type must be one of"): + client.generate("x", input_type="img") + + +def test_input_type_mismatch_is_left_to_the_gateway(client, captured): + """Declaring a mode that contradicts the seed fields must still be sent. + + The gateway owns that check and answers 400 before charging; rejecting it + here would fork the inference into a second copy that drifts. + """ + client.generate("x", input_type="image") # no image_url — gateway's call + assert captured["body"]["input_type"] == "image" From e57e0f9629b2879e5bb69ef8a78ed6e91a3c562e Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 15 Jul 2026 16:37:10 -0500 Subject: [PATCH 2/4] =?UTF-8?q?release:=201.7.0=20=E2=80=94=20video=20inpu?= =?UTF-8?q?t=5Ftype=20+=20Solana=20image=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blockrun_llm/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/blockrun_llm/__init__.py b/blockrun_llm/__init__.py index e837bb9..c33f15a 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.6.1" +__version__ = "1.7.0" __all__ = [ "LLMClient", "AsyncLLMClient", diff --git a/pyproject.toml b/pyproject.toml index b343ca5..6aa8f94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "blockrun-llm" -version = "1.6.1" +version = "1.7.0" description = "BlockRun SDK - Pay-per-request AI (LLM, Image, Video, Music, Voice) via x402 on Base and Solana" readme = "README.md" license = "MIT" From 99b1611c216438829fed3f5ab1a8bad18ef51b22 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 15 Jul 2026 16:48:58 -0500 Subject: [PATCH 3/4] review: real async body assertions, docs, and validator consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review of #24: - Replace test_async_video_exposes_input_type, which only checked the signature and would have passed if the param were accepted then never forwarded — the one regression worth catching. The async paths now assert the forwarded JSON body, matching the sync coverage, and cover async image quality (previously untested on all four methods). - Call validate_image_quality unconditionally, as validate_video_input_type already is; the validator no-ops on None, so the guard was redundant. - Make _build_video_body's input_type required like its siblings. Every param being explicit is exactly how the builder prevents the drift it exists for. - Point the Base ImageClient TypeError at the Solana client when quality is the offending kwarg, so the error names where the param does exist. - Document input_type and Solana quality in the README. input_type earns the worked example: its value is the 400 you get instead of a text-to-video clip you paid for. - Backfill CHANGELOG 1.5.0/1.5.1/1.6.0/1.6.1 (dates from PyPI) and add 1.7.0. The file claimed "all notable changes" but stopped at 1.4.7. --- CHANGELOG.md | 53 +++++++++++++++++++++++++++++++++ README.md | 27 +++++++++++++++++ blockrun_llm/image.py | 16 ++++++++-- blockrun_llm/solana_client.py | 15 ++++++---- tests/unit/test_solana_media.py | 52 ++++++++++++++++++++++++++++++-- 5 files changed, 152 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e5e7a9..c780e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d8fe17e..4773f28 100644 --- a/README.md +++ b/README.md @@ -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 | |-------|-------|-----------------| @@ -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`) diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index c387ad3..c46f18c 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -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 @@ -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] = { diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index e886b8d..1ea6ffd 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -1776,8 +1776,8 @@ def image( "size": size, "n": n, } + validate_image_quality(quality) if quality is not None: - validate_image_quality(quality) body["quality"] = quality data = self._request_image_with_payment("/v1/images/generations", body, timeout=timeout) return ImageResponse(**data) @@ -1817,8 +1817,8 @@ def image_edit( } if mask is not None: body["mask"] = mask + validate_image_quality(quality) if quality is not None: - validate_image_quality(quality) body["quality"] = quality data = self._request_image_with_payment("/v1/images/image2image", body, timeout=timeout) @@ -2209,10 +2209,13 @@ def _build_video_body( seed: Optional[int], watermark: Optional[bool], return_last_frame: Optional[bool], - input_type: Optional[str] = None, + 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." @@ -3586,8 +3589,8 @@ async def image( "size": size, "n": n, } + validate_image_quality(quality) if quality is not None: - validate_image_quality(quality) body["quality"] = quality data = await self._request_image_with_payment( "/v1/images/generations", body, timeout=timeout @@ -3627,8 +3630,8 @@ async def image_edit( } if mask is not None: body["mask"] = mask + validate_image_quality(quality) if quality is not None: - validate_image_quality(quality) body["quality"] = quality data = await self._request_image_with_payment( diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index 79ce80d..46c590c 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -671,7 +671,53 @@ def test_input_type_reaches_body(self) -> None: ) assert body["input_type"] == "text" - def test_async_video_exposes_input_type(self) -> None: - import inspect - assert "input_type" in inspect.signature(AsyncSolanaLLMClient.video).parameters +class TestAsyncMediaParamParity: + """The async client duplicates the sync call shape, so it needs the same + body assertions. A signature check would pass even if the param were + accepted and then never forwarded — the regression worth catching. + """ + + @pytest.mark.asyncio + async def test_async_video_forwards_input_type(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_async_client(_paid_flow(calls, _VIDEO_OK)) + await client.video("a calm lake", input_type="text") + assert json.loads(calls[-1].content)["input_type"] == "text" + + @pytest.mark.asyncio + async def test_async_video_rejects_unknown_input_type_before_paying(self) -> None: + calls: List[httpx.Request] = [] + client = _make_async_client(_paid_flow(calls, _VIDEO_OK)) + with pytest.raises(ValueError, match="input_type must be one of"): + await client.video("x", input_type="img") + assert calls == [] + + @pytest.mark.asyncio + async def test_async_image_forwards_quality(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_async_client(_paid_flow(calls, _IMAGE_OK)) + await client.image("a cat", model="openai/gpt-image-2", quality="low") + assert json.loads(calls[-1].content)["quality"] == "low" + + @pytest.mark.asyncio + async def test_async_image_edit_forwards_quality(self) -> None: + import json + + calls: List[httpx.Request] = [] + client = _make_async_client(_paid_flow(calls, _IMAGE_OK)) + await client.image_edit("make it green", _DATA_URI, quality="high") + assert json.loads(calls[-1].content)["quality"] == "high" + assert calls[-1].url.path == "/api/v1/images/image2image" + + @pytest.mark.asyncio + async def test_async_image_rejects_unknown_quality_before_paying(self) -> None: + calls: List[httpx.Request] = [] + client = _make_async_client(_paid_flow(calls, _IMAGE_OK)) + with pytest.raises(ValueError, match="quality must be one of"): + await client.image("a cat", quality="hd") + assert calls == [] From 9117b09d65c9dea7d170f018cd91aaf61e807364 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Wed, 15 Jul 2026 16:50:51 -0500 Subject: [PATCH 4/4] style: black formatting for the new media param tests --- tests/unit/test_solana_media.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index 46c590c..92e06f3 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -557,7 +557,11 @@ async def test_async_image_path_never_resigns(self, monkeypatch: pytest.MonkeyPa _IMAGE_OK = {"created": 1, "model": "openai/gpt-image-2", "data": [{"url": "https://cdn/x.png"}]} -_VIDEO_OK = {"created": 1, "model": "xai/grok-imagine-video", "data": [{"url": "https://cdn/x.mp4"}]} +_VIDEO_OK = { + "created": 1, + "model": "xai/grok-imagine-video", + "data": [{"url": "https://cdn/x.mp4"}], +} _DATA_URI = "data:image/png;base64,AA=="