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

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

## Unreleased

### Added
- **Seedance reference media and output controls** on `VideoClient.generate`,
`SolanaLLMClient.video` and `AsyncSolanaLLMClient.video`:
- `reference_videos` and `reference_audios` (up to 3 each) on seedance-2.0 /
2.0-fast / 2.0-mini / 2.5.
- Up to 30 `reference_image_urls` on seedance-2.5.
- `bitrate_mode`, `output_format`, `camera_fixed` and `safety_identifier`.
- `VideoClip.last_frame_url` / `last_frame_backed_up`.

Reference media is served only on the account rail (`BLOCKRUN_API_KEY`).
Reference clips are billed at the model's reference ceiling each (15.2s on
the 2.0 family, 30.2s on 2.5), so they can multiply a render's price. See `docs/seedance-capabilities.md`. Contributed
by @KillerQueen-Z (#70).

### Changed
- **Seedance requests are checked per model before anything is sent.** The
check is shared by the Base and Solana clients and mirrors the MCP's table.
Reference fields on a wallet rail, reference images or clips on models that
do not take them, audio without an image or video, `output_format` off
2.5, `bitrate_mode` off 2.x, `camera_fixed` off 1.5-pro, and seedance-2.5
first-and-last-frame on the Solana wallet gateway all raise `ValueError`
locally.

These previously failed as a gateway 400. On the account rail, which has no
quote step, they could reach a billed submit. Reference fields on the
wallet rails also used to be forwarded and then refused by the gateway;
they now fail fast with a pointer to the account rail.

### Fixed
- **`SolanaLLMClient.video()` / `image()` with an API key no longer bill and
then crash.**
- On the account rail the first POST is the billed submit. Its 202 job stub
was returned as the result, so `VideoResponse` raised a validation error
after the charge, with no job id. The job is now polled to completion,
unsigned.
- A 502/503 on that POST is no longer replayed, because a replay could bill
the job twice.
- A timeout now says credit was reserved at accept (charged only on
completion), and the error carries the `poll_url` so the job can still be
fetched.
- Both the sync and async clients are fixed.
- **Solana `music()`, `speech()` and `sound_effect()` with an API key** get
the same treatment, sync and async: no 5xx replay of the billed submit, and
a 202 job is polled unsigned to completion instead of failing
`MusicResponse` validation after the charge.
- **An API key is never sent to a foreign `poll_url` origin.** `VideoClient`
and the shared image/music poller returned an absolute `poll_url` before
the origin pin ran, so a response naming another host received
`Authorization: Bearer brk_...`. Every poll URL is now pinned; a refusal
raises `PollOriginRefusedError` (an `APIError`, and a `ValueError` as the
Solana account rail raised before) carrying the job id and `poll_url`.

## 1.17.1 — 2026-09-30

### Fixed
Expand Down
46 changes: 38 additions & 8 deletions blockrun_llm/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,41 @@
from .validation import build_payment_rejected_error, sanitize_error_response


def absolute_poll_url(url: str, api_url: str, api_key: str | None) -> str:
"""Resolve a relative ``poll_url`` against the configured API host.
class PollOriginRefusedError(APIError, ValueError):
"""A ``poll_url`` named an origin the API key may not be sent to.

An :class:`APIError` because the job was already accepted (``response``
carries its ``id`` and the refused ``poll_url``), and still a
``ValueError`` because that is what the Solana account rail raised for
this refusal before, so existing handlers keep catching it.
"""


def absolute_poll_url(
url: str, api_url: str, api_key: str | None, job_id: str | None = None
) -> str:
"""Resolve a server-supplied ``poll_url`` against the configured API host.

Server-returned poll URLs look like ``/api/v1/images/generations/<id>``;
``api_url`` already ends with ``/api`` on the wallet rail, and the account
rail serves the same route without that prefix.

Absolute URLs go through :func:`resolve_poll_url` too: with an API key it
pins them to the gateway's own origin, since every poll carries the key
as ``Authorization: Bearer``. Returning them before that check would hand
the key to any host a response named. The refusal comes after the job was
accepted (and, on the account rail, billed), so it is raised as an
:class:`PollOriginRefusedError` carrying the job id and the refused
``poll_url``.
"""
if url.startswith(("http://", "https://")):
return url
return resolve_poll_url(url, api_url, api_key)
try:
return resolve_poll_url(url, api_url, api_key)
except ValueError as exc:
raise PollOriginRefusedError(
f"{exc} The job was accepted; poll_url {url!r} is not on {api_url}.",
502,
{"id": job_id, "poll_url": url},
) from exc


def poll_until_completed(
Expand Down Expand Up @@ -59,7 +84,7 @@ def poll_until_completed(
if not poll_url_rel:
raise APIError("Slow-path 202 missing poll_url", 202, {"response": submit_data})

poll_url = absolute_poll_url(poll_url_rel, api_url, api_key)
poll_url = absolute_poll_url(poll_url_rel, api_url, api_key, job_id)
poll_headers = {"PAYMENT-SIGNATURE": payment_payload} if payment_payload else {}
deadline = time.monotonic() + budget_seconds
last_status = submit_data.get("status", "queued")
Expand Down Expand Up @@ -115,8 +140,13 @@ def poll_until_completed(
raise APIError(
(
f"{label} generation did not complete within {budget_seconds:.0f}s "
f"(last status: {last_status}). Settlement only happens on "
"completion, so no payment was taken."
f"(last status: {last_status}). "
+ (
"Credit was reserved when the job was accepted and is charged only "
"on completion; re-poll poll_url with the same API key to fetch it."
if api_key
else "Settlement only happens on completion, so no payment was taken."
)
),
504,
{"id": job_id, "last_status": last_status},
Expand Down
Loading
Loading