diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eacdf2..fd0e320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/blockrun_llm/jobs.py b/blockrun_llm/jobs.py index dccc924..dc1b3b5 100644 --- a/blockrun_llm/jobs.py +++ b/blockrun_llm/jobs.py @@ -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/``; ``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( @@ -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") @@ -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}, diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 32517f5..d43060d 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -36,7 +36,6 @@ payment_mode, raise_for_api_key_402, resolve_api_key, - resolve_poll_url, wallet_only, ) @@ -45,6 +44,7 @@ # in both fallback chains. client.py does not import this module, so there is # no cycle. from .client import _SETTLED_ATTR, _enforce_spend_limits, _mark_settled +from .jobs import absolute_poll_url from .price import Category, Market, Resolution, Session from .realface import _GROUP_ID_RE from .router_adapter import ( @@ -101,6 +101,7 @@ validate_image_quality, validate_max_tokens, validate_video_input_type, + validate_video_request, ) try: @@ -578,6 +579,12 @@ class SolanaLLMClient: # settling poll lands. 25s keeps every signature comfortably fresh. MEDIA_RESIGN_FRESH_SECONDS = 25.0 + # Audio jobs (music, speech, sound effects) on the account rail. With a key + # the first POST is the billed submit, and a track answers 202 + poll_url + # at once, so the wait happens poll by poll. Mirrors the Base MusicClient. + AUDIO_POLL_INTERVAL_SECONDS = 5.0 + AUDIO_POLL_BUDGET_SECONDS = 300.0 + # Media generation defaults (mirror the Base MusicClient/SpeechClient). MUSIC_DEFAULT_MODEL = "minimax/music-2.5+" SPEECH_DEFAULT_MODEL = "elevenlabs/flash-v2.5" @@ -1933,7 +1940,7 @@ def _handle_get_payment_and_retry( return retry_response.json() - def _absolute_url(self, url: str) -> str: + def _absolute_url(self, url: str, job_id: str | None = None) -> str: """Resolve a server-supplied relative ``poll_url`` against the API host. Poll URLs come back as ``/api/v1/images/generations/``; our @@ -1944,8 +1951,9 @@ def _absolute_url(self, url: str) -> str: # api.blockrun.ai serves these routes at /v1/... and answers # /api/v1/... with wrong_host, so the gateway-minted prefix has to # come off. Shared with the Base clients, which also pins the - # Authorization header to the gateway's own origin. - return resolve_poll_url(url, self._api_url, self.api_key) + # Authorization header to the gateway's own origin; a refusal is + # an APIError (still a ValueError) naming the accepted job. + return absolute_poll_url(url, self._api_url, self.api_key, job_id) base = self._api_url.removesuffix("/api") if url.startswith(("http://", "https://")): # The poll loop sends (and re-signs) the wallet's PAYMENT-SIGNATURE @@ -1980,8 +1988,9 @@ def _request_image_with_payment( 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`` / + on the completed poll), and by the account-rail audio calls via + :meth:`_request_audio_with_payment`. 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. @@ -2021,7 +2030,10 @@ def _request_image_with_payment( # Step 1: probe — expect 402 unless the model is free or cached upstream. probe = self._client.post(url, json=body, headers=probe_headers, timeout=eff_timeout) - if probe.status_code in (502, 503): + # Not on the account rail: there the first POST is the billed submit + # (the key IS the payment), and a 502 can arrive after the charge, so + # replaying it could bill the job twice. Mirrors the Base VideoClient. + if probe.status_code in (502, 503) and not self.api_key: _time.sleep(1) probe = self._client.post(url, json=body, headers=probe_headers, timeout=eff_timeout) @@ -2030,14 +2042,21 @@ def _request_image_with_payment( # and, without the optional SDK installed, no decoder either. raise_for_api_key_402(probe, self.api_key) - if probe.status_code != 402: + # Account rail: the probe carried the API key, so a 202 here is the + # accepted (and already billed) job, not a free/cached answer. Handing + # its {id, poll_url, status} stub back as the result crashed the caller + # (VideoResponse validation) after the charge, with no job id surfaced. + # Poll it like the wallet rail does, minus the signature. + account_job = bool(self.api_key) and probe.status_code == 202 + + if probe.status_code != 402 and not account_job: if not probe.is_success: try: error_body = probe.json() except Exception: error_body = {"error": "Request failed"} raise APIError( - f"Image request: HTTP {probe.status_code}", + f"{label} request: HTTP {probe.status_code}", probe.status_code, sanitize_error_response(error_body), retry_after=retry_after_of(probe), @@ -2045,61 +2064,73 @@ def _request_image_with_payment( # Free / cached upstream — return whatever the gateway gave us. return probe.json() - # Step 2: sign x402 SVM payload. - payment_header_str = self._extract_payment_header(probe) - if not payment_header_str: - raise PaymentError("402 response but no payment requirements found") - - payment_required = decode_payment_required_header(payment_header_str) - payment_payload_obj = self._sign_payment(payment_required) - encoded_payment = encode_payment_signature_header(payment_payload_obj) - cost_usd = float(payment_payload_obj.accepted.amount) / 1e6 - # Terms this job is authorized to pay — any mid-poll re-sign must match. - orig_amount = payment_payload_obj.accepted.amount - orig_pay_to = payment_payload_obj.accepted.pay_to - - paid_headers = { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": encoded_payment, - } + if account_job: + submit_resp = probe + payment_required = None + encoded_payment = None + # The SDK cannot see the account rail's settled cost, so nothing is + # booked locally (same as every other account-rail call). + cost_usd = 0.0 + orig_amount = orig_pay_to = None + max_resigns = 0 + else: + # Step 2: sign x402 SVM payload. + payment_header_str = self._extract_payment_header(probe) + if not payment_header_str: + raise PaymentError("402 response but no payment requirements found") + + payment_required = decode_payment_required_header(payment_header_str) + payment_payload_obj = self._sign_payment(payment_required) + encoded_payment = encode_payment_signature_header(payment_payload_obj) + cost_usd = float(payment_payload_obj.accepted.amount) / 1e6 + # Terms this job is authorized to pay — any mid-poll re-sign must match. + orig_amount = payment_payload_obj.accepted.amount + orig_pay_to = payment_payload_obj.accepted.pay_to + + paid_headers = { + "Content-Type": "application/json", + "User-Agent": _get_user_agent(), + "PAYMENT-SIGNATURE": encoded_payment, + } - # Step 3: submit with signature. - submit_resp = self._client.post(url, json=body, headers=paid_headers, timeout=eff_timeout) - if submit_resp.status_code in (502, 503): - _time.sleep(1) + # Step 3: submit with signature. submit_resp = self._client.post( url, json=body, headers=paid_headers, timeout=eff_timeout ) + if submit_resp.status_code in (502, 503): + _time.sleep(1) + submit_resp = self._client.post( + url, json=body, headers=paid_headers, timeout=eff_timeout + ) - if submit_resp.status_code == 402: - # Account rail: a 402 is the account being out of credit, not a - # challenge to sign. Nothing here can sign, so say so plainly. - raise_for_api_key_402(submit_resp, self.api_key) - raise build_payment_rejected_error(submit_resp) - - if submit_resp.status_code == 200: - # Fast path — image was produced inline. - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd - self._capture_settlement(submit_resp) - data = submit_resp.json() - save_to_cache(endpoint, body, data, cost_usd=cost_usd, **self._billing_meta()) - self._log_transaction(endpoint, body, data, cost_usd) - return data - - if submit_resp.status_code != 202: - try: - error_body = submit_resp.json() - except Exception: - error_body = {"error": "Request failed"} - raise APIError( - f"Image request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", - submit_resp.status_code, - sanitize_error_response(error_body), - retry_after=retry_after_of(submit_resp), - ) + if submit_resp.status_code == 402: + # Account rail: a 402 is the account being out of credit, not a + # challenge to sign. Nothing here can sign, so say so plainly. + raise_for_api_key_402(submit_resp, self.api_key) + raise build_payment_rejected_error(submit_resp) + + if submit_resp.status_code == 200: + # Fast path — image was produced inline. + self._session_calls += 1 + self._session_total_usd += cost_usd + self._last_call_cost = cost_usd + self._capture_settlement(submit_resp) + data = submit_resp.json() + save_to_cache(endpoint, body, data, cost_usd=cost_usd, **self._billing_meta()) + self._log_transaction(endpoint, body, data, cost_usd) + return data + + if submit_resp.status_code != 202: + try: + error_body = submit_resp.json() + except Exception: + error_body = {"error": "Request failed"} + raise APIError( + f"{label} request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", + submit_resp.status_code, + sanitize_error_response(error_body), + retry_after=retry_after_of(submit_resp), + ) # Step 4: slow path — poll until completed (or budget exhausted). try: @@ -2115,8 +2146,12 @@ def _request_image_with_payment( 202, {"response": submit_data}, ) - poll_url = self._absolute_url(poll_url_rel) - if settled_at_submit: + poll_url = self._absolute_url(poll_url_rel, job_id) + # Neither rail books spend on completion here: a settled-at-submit + # wallet route booked it above, and the account rail has no x402 charge + # (it reserves credit at accept and settles it when the job completes). + charged_at_submit = settled_at_submit or account_job + if settled_at_submit and not account_job: # 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 @@ -2124,10 +2159,9 @@ def _request_image_with_payment( 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, - } + poll_headers = {"User-Agent": _get_user_agent()} + if encoded_payment is not None: + poll_headers["PAYMENT-SIGNATURE"] = encoded_payment budget = ( poll_budget_seconds @@ -2226,7 +2260,11 @@ def _request_image_with_payment( if last_status == "failed": raise APIError( f"{label} failed upstream: {poll_data.get('error', 'unknown')}" - + (" (payment was settled at submit)" if settled_at_submit else ""), + + ( + " (credit reserved at accept is released, not charged)" + if account_job + else " (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), @@ -2242,7 +2280,7 @@ def _request_image_with_payment( ) if tx_hash and isinstance(poll_data, dict) and not poll_data.get("txHash"): poll_data["txHash"] = tx_hash - if not settled_at_submit: + if not charged_at_submit: self._session_calls += 1 self._session_total_usd += cost_usd self._last_call_cost = cost_usd @@ -2272,17 +2310,27 @@ def _request_image_with_payment( ( ( 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." + f"(last status: {last_status}). Credit was reserved when the job " + "was accepted and is charged only when it completes; the job " + "stays claimable for ~48h — re-poll poll_url with the same API " + "key to fetch the result." ) - if settled_at_submit + if account_job 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." + ( + 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, @@ -2402,6 +2450,12 @@ def video( image_url: str | None = None, last_frame_url: str | None = None, reference_image_urls: list[str] | None = None, + reference_videos: list[dict[str, str]] | None = None, + reference_audios: list[dict[str, str]] | None = None, + bitrate_mode: str | None = None, + output_format: str | None = None, + camera_fixed: bool | None = None, + safety_identifier: str | None = None, real_face_asset_id: str | None = None, duration_seconds: int | None = None, aspect_ratio: str | None = None, @@ -2422,6 +2476,14 @@ def video( **no payment** and leaves the job claimable ~48h. Default model is ``xai/grok-imagine-video``. + Every kwarg is documented on ``VideoClient.generate``. Rail notes: + reference media (``reference_image_urls`` / ``reference_videos`` / + ``reference_audios``) needs the account rail (a ``brk_`` key) — the + Solana wallet gateway refuses it — and seedance-2.5 + first-and-last-frame is not served on the Solana wallet gateway yet. + Both are refused locally, before any request. On the account rail the + job's credit is reserved when accepted and charged when it completes. + Args: input_type: Optional assertion of the seed mode — ``text`` / ``image`` / ``first_last_frame`` / ``reference``. The gateway @@ -2435,6 +2497,12 @@ def video( image_url=image_url, last_frame_url=last_frame_url, reference_image_urls=reference_image_urls, + reference_videos=reference_videos, + reference_audios=reference_audios, + bitrate_mode=bitrate_mode, + output_format=output_format, + camera_fixed=camera_fixed, + safety_identifier=safety_identifier, real_face_asset_id=real_face_asset_id, duration_seconds=duration_seconds, aspect_ratio=aspect_ratio, @@ -2444,6 +2512,7 @@ def video( watermark=watermark, return_last_frame=return_last_frame, input_type=input_type, + api_key_mode=bool(self.api_key), ) data = self._request_image_with_payment( @@ -2496,6 +2565,29 @@ def video_from_content( # Music generation (Solana payment) # ------------------------------------------------------------------ + def _request_audio_with_payment( + self, endpoint: str, body: dict[str, Any], timeout: float | None, label: str + ) -> dict[str, Any]: + """POST an audio request (music, speech, sound effect) on either rail. + + Wallet rail: the raw x402 helper, unchanged. Account rail: the first + POST carries the key and is the billed submit, so it goes through the + media helper instead, which never replays it on a 502/503 (that could + bill twice) and polls a 202 job to completion unsigned rather than + returning the ``{id, poll_url}`` stub as the result. + """ + if not self.api_key: + return self._request_with_payment_raw(endpoint, body, timeout=timeout) + self._last_raw_headers = None + return self._request_image_with_payment( + endpoint, + body, + timeout=timeout if timeout is not None else self._timeout, + poll_budget_seconds=self.AUDIO_POLL_BUDGET_SECONDS, + poll_interval_seconds=self.AUDIO_POLL_INTERVAL_SECONDS, + label=label, + ) + def music( self, prompt: str, @@ -2519,7 +2611,9 @@ def music( } if lyrics and lyrics.strip(): body["lyrics"] = lyrics.strip() - data = self._request_with_payment_raw("/v1/audio/generations", body, timeout=timeout) + data = self._request_audio_with_payment( + "/v1/audio/generations", body, timeout, "Music generation" + ) self._attach_receipt(data) return MusicResponse(**data) @@ -2553,7 +2647,7 @@ def speech( body["response_format"] = response_format if speed is not None: body["speed"] = speed - data = self._request_with_payment_raw("/v1/audio/speech", body, timeout=timeout) + data = self._request_audio_with_payment("/v1/audio/speech", body, timeout, "Speech") self._attach_receipt(data) return SpeechResponse(**data) @@ -2579,7 +2673,9 @@ def sound_effect( body["prompt_influence"] = prompt_influence if response_format: body["response_format"] = response_format - data = self._request_with_payment_raw("/v1/audio/sound-effects", body, timeout=timeout) + data = self._request_audio_with_payment( + "/v1/audio/sound-effects", body, timeout, "Sound effect" + ) self._attach_receipt(data) return SpeechResponse(**data) @@ -2774,6 +2870,12 @@ def _build_video_body( image_url: str | None, last_frame_url: str | None, reference_image_urls: list[str] | None, + reference_videos: list[dict[str, str]] | None, + reference_audios: list[dict[str, str]] | None, + bitrate_mode: str | None, + output_format: str | None, + camera_fixed: bool | None, + safety_identifier: str | None, real_face_asset_id: str | None, duration_seconds: int | None, aspect_ratio: str | None, @@ -2783,43 +2885,33 @@ def _build_video_body( watermark: bool | None, return_last_frame: bool | None, input_type: str | None, + api_key_mode: bool, ) -> 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. 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." - ) - if last_frame_url and not image_url: - raise ValueError( - "last_frame_url requires image_url: image_url seeds the FIRST frame and " - "last_frame_url the FINAL frame — send both." - ) - if last_frame_url and real_face_asset_id: - raise ValueError( - "last_frame_url and real_face_asset_id are mutually exclusive; " - "first-and-last-frame uses image_url + last_frame_url." - ) - if reference_image_urls: - if image_url or last_frame_url or real_face_asset_id: - raise ValueError( - "reference_image_urls is mutually exclusive with image_url, " - "last_frame_url, and real_face_asset_id." - ) - if len(reference_image_urls) > 9: - raise ValueError("reference_image_urls accepts at most 9 images.") - if real_face_asset_id is not None and not real_face_asset_id.startswith("ta_"): - raise ValueError( - "real_face_asset_id must start with 'ta_' " - "(a Virtual Portrait or RealFace asset id, e.g. 'ta_abc123xyz')" - ) + resolved_model = model or SolanaLLMClient.VIDEO_DEFAULT_MODEL + validate_video_request( + resolved_model, + api_key_mode=api_key_mode, + solana_wallet=not api_key_mode, + image_url=image_url, + last_frame_url=last_frame_url, + reference_image_urls=reference_image_urls, + reference_videos=reference_videos, + reference_audios=reference_audios, + real_face_asset_id=real_face_asset_id, + bitrate_mode=bitrate_mode, + output_format=output_format, + camera_fixed=camera_fixed, + safety_identifier=safety_identifier, + ) validate_video_input_type(input_type) body: dict[str, Any] = { - "model": model or SolanaLLMClient.VIDEO_DEFAULT_MODEL, + "model": resolved_model, "prompt": prompt, } if image_url: @@ -2828,6 +2920,18 @@ def _build_video_body( body["last_frame_url"] = last_frame_url if reference_image_urls: body["reference_image_urls"] = reference_image_urls + if reference_videos: + body["reference_videos"] = reference_videos + if reference_audios: + body["reference_audios"] = reference_audios + if bitrate_mode is not None: + body["bitrate_mode"] = bitrate_mode + if output_format is not None: + body["output_format"] = output_format + if camera_fixed is not None: + body["camera_fixed"] = camera_fixed + if safety_identifier is not None: + body["safety_identifier"] = safety_identifier if real_face_asset_id: body["real_face_asset_id"] = real_face_asset_id if duration_seconds is not None: @@ -4575,15 +4679,16 @@ async def image_edit( ) return ImageResponse(**data) - def _absolute_url(self, url: str) -> str: + def _absolute_url(self, url: str, job_id: str | None = None) -> str: """Resolve a server-supplied relative ``poll_url`` against the API host (``api_url`` already includes the trailing ``/api`` — strip it once).""" if self.api_key: # api.blockrun.ai serves these routes at /v1/... and answers # /api/v1/... with wrong_host, so the gateway-minted prefix has to # come off. Shared with the Base clients, which also pins the - # Authorization header to the gateway's own origin. - return resolve_poll_url(url, self._api_url, self.api_key) + # Authorization header to the gateway's own origin; a refusal is + # an APIError (still a ValueError) naming the accepted job. + return absolute_poll_url(url, self._api_url, self.api_key, job_id) base = self._api_url.removesuffix("/api") if url.startswith(("http://", "https://")): # The poll loop sends (and re-signs) the wallet's PAYMENT-SIGNATURE @@ -4612,6 +4717,12 @@ async def video( image_url: str | None = None, last_frame_url: str | None = None, reference_image_urls: list[str] | None = None, + reference_videos: list[dict[str, str]] | None = None, + reference_audios: list[dict[str, str]] | None = None, + bitrate_mode: str | None = None, + output_format: str | None = None, + camera_fixed: bool | None = None, + safety_identifier: str | None = None, real_face_asset_id: str | None = None, duration_seconds: int | None = None, aspect_ratio: str | None = None, @@ -4632,6 +4743,12 @@ async def video( image_url=image_url, last_frame_url=last_frame_url, reference_image_urls=reference_image_urls, + reference_videos=reference_videos, + reference_audios=reference_audios, + bitrate_mode=bitrate_mode, + output_format=output_format, + camera_fixed=camera_fixed, + safety_identifier=safety_identifier, real_face_asset_id=real_face_asset_id, duration_seconds=duration_seconds, aspect_ratio=aspect_ratio, @@ -4641,6 +4758,7 @@ async def video( watermark=watermark, return_last_frame=return_last_frame, input_type=input_type, + api_key_mode=bool(self.api_key), ) data = await self._request_image_with_payment( @@ -4688,6 +4806,24 @@ async def video_from_content( ) return VideoResponse(**data) + async def _request_audio_with_payment( + self, endpoint: str, body: dict[str, Any], timeout: float | None, label: str + ) -> dict[str, Any]: + """Async mirror of :meth:`SolanaLLMClient._request_audio_with_payment`: + wallet rail unchanged; on the account rail the keyed first POST is the + billed submit, so no 5xx replay and a 202 job is polled unsigned.""" + if not self.api_key: + return await self._request_with_payment_raw(endpoint, body, timeout=timeout) + self._last_raw_headers = None + return await self._request_image_with_payment( + endpoint, + body, + timeout=timeout if timeout is not None else self._timeout, + poll_budget_seconds=SolanaLLMClient.AUDIO_POLL_BUDGET_SECONDS, + poll_interval_seconds=SolanaLLMClient.AUDIO_POLL_INTERVAL_SECONDS, + label=label, + ) + async def music( self, prompt: str, @@ -4707,7 +4843,9 @@ async def music( } if lyrics and lyrics.strip(): body["lyrics"] = lyrics.strip() - data = await self._request_with_payment_raw("/v1/audio/generations", body, timeout=timeout) + data = await self._request_audio_with_payment( + "/v1/audio/generations", body, timeout, "Music generation" + ) self._attach_receipt(data) return MusicResponse(**data) @@ -4732,7 +4870,7 @@ async def speech( body["response_format"] = response_format if speed is not None: body["speed"] = speed - data = await self._request_with_payment_raw("/v1/audio/speech", body, timeout=timeout) + data = await self._request_audio_with_payment("/v1/audio/speech", body, timeout, "Speech") self._attach_receipt(data) return SpeechResponse(**data) @@ -4757,8 +4895,8 @@ async def sound_effect( body["prompt_influence"] = prompt_influence if response_format: body["response_format"] = response_format - data = await self._request_with_payment_raw( - "/v1/audio/sound-effects", body, timeout=timeout + data = await self._request_audio_with_payment( + "/v1/audio/sound-effects", body, timeout, "Sound effect" ) self._attach_receipt(data) return SpeechResponse(**data) @@ -5073,7 +5211,10 @@ async def _request_image_with_payment( # Step 1: probe — expect 402 unless the model is free or cached upstream. probe = await self._client.post(url, json=body, headers=probe_headers, timeout=eff_timeout) - if probe.status_code in (502, 503): + # Not on the account rail: there the first POST is the billed submit + # (the key IS the payment), and a 502 can arrive after the charge, so + # replaying it could bill the job twice. Mirrors the Base VideoClient. + if probe.status_code in (502, 503) and not self.api_key: await asyncio.sleep(1) probe = await self._client.post( url, json=body, headers=probe_headers, timeout=eff_timeout @@ -5084,77 +5225,94 @@ async def _request_image_with_payment( # and, without the optional SDK installed, no decoder either. raise_for_api_key_402(probe, self.api_key) - if probe.status_code != 402: + # Account rail: the probe carried the API key, so a 202 here is the + # accepted (and already billed) job, not a free/cached answer. Handing + # its {id, poll_url, status} stub back as the result crashed the caller + # (VideoResponse validation) after the charge, with no job id surfaced. + # Poll it like the wallet rail does, minus the signature. + account_job = bool(self.api_key) and probe.status_code == 202 + + if probe.status_code != 402 and not account_job: if not probe.is_success: try: error_body = probe.json() except Exception: error_body = {"error": "Request failed"} raise APIError( - f"Image request: HTTP {probe.status_code}", + f"{label} request: HTTP {probe.status_code}", probe.status_code, sanitize_error_response(error_body), retry_after=retry_after_of(probe), ) return probe.json() - # Step 2: sign x402 SVM payload (reuse the encoded signature on polls). - # Inlined rather than _sign_payment_from_response so the original payment - # terms are captured for the mid-poll re-sign guard below. - probe_payment_header = SolanaLLMClient._extract_payment_header(probe) - if not probe_payment_header: - raise PaymentError("402 response but no payment requirements found") - payment_required = decode_payment_required_header(probe_payment_header) - payment_payload_obj = await self._sign_payment(payment_required) - encoded_payment = encode_payment_signature_header(payment_payload_obj) - cost_usd = float(payment_payload_obj.accepted.amount) / 1e6 - # Terms this job is authorized to pay — any mid-poll re-sign must match. - orig_amount = payment_payload_obj.accepted.amount - orig_pay_to = payment_payload_obj.accepted.pay_to - payment_headers = { - "Content-Type": "application/json", - "User-Agent": _get_user_agent(), - "PAYMENT-SIGNATURE": encoded_payment, - } + if account_job: + submit_resp = probe + payment_required = None + encoded_payment = None + # The SDK cannot see the account rail's settled cost, so nothing is + # booked locally (same as every other account-rail call). + cost_usd = 0.0 + orig_amount = orig_pay_to = None + max_resigns = 0 + else: + # Step 2: sign x402 SVM payload (reuse the encoded signature on polls). + # Inlined rather than _sign_payment_from_response so the original payment + # terms are captured for the mid-poll re-sign guard below. + probe_payment_header = SolanaLLMClient._extract_payment_header(probe) + if not probe_payment_header: + raise PaymentError("402 response but no payment requirements found") + payment_required = decode_payment_required_header(probe_payment_header) + payment_payload_obj = await self._sign_payment(payment_required) + encoded_payment = encode_payment_signature_header(payment_payload_obj) + cost_usd = float(payment_payload_obj.accepted.amount) / 1e6 + # Terms this job is authorized to pay — any mid-poll re-sign must match. + orig_amount = payment_payload_obj.accepted.amount + orig_pay_to = payment_payload_obj.accepted.pay_to + payment_headers = { + "Content-Type": "application/json", + "User-Agent": _get_user_agent(), + "PAYMENT-SIGNATURE": encoded_payment, + } - # Step 3: submit with signature. - submit_resp = await self._client.post( - url, json=body, headers=payment_headers, timeout=eff_timeout - ) - if submit_resp.status_code in (502, 503): - await asyncio.sleep(1) + # Step 3: submit with signature. submit_resp = await self._client.post( url, json=body, headers=payment_headers, timeout=eff_timeout ) + if submit_resp.status_code in (502, 503): + await asyncio.sleep(1) + submit_resp = await self._client.post( + url, json=body, headers=payment_headers, timeout=eff_timeout + ) - if submit_resp.status_code == 402: - # Account rail: a 402 is the account being out of credit, not a - # challenge to sign. Nothing here can sign, so say so plainly. - raise_for_api_key_402(submit_resp, self.api_key) - raise build_payment_rejected_error(submit_resp) - - if submit_resp.status_code == 200: - # Fast path — image produced inline. - self._session_calls += 1 - self._session_total_usd += cost_usd - self._last_call_cost = cost_usd - self._capture_settlement(submit_resp) - data = submit_resp.json() - save_to_cache(endpoint, body, data, cost_usd=cost_usd, **self._billing_meta()) - self._log_transaction(endpoint, body, data, cost_usd) - return data - - if submit_resp.status_code != 202: - try: - error_body = submit_resp.json() - except Exception: - error_body = {"error": "Request failed"} - raise APIError( - f"Image request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", - submit_resp.status_code, - sanitize_error_response(error_body), - retry_after=retry_after_of(submit_resp), - ) + if submit_resp.status_code == 402: + # Account rail: a 402 is the account being out of credit, not a + # challenge to sign. Nothing here can sign, so say so plainly. + raise_for_api_key_402(submit_resp, self.api_key) + raise build_payment_rejected_error(submit_resp) + + if submit_resp.status_code == 200: + # Fast path — image produced inline. + self._session_calls += 1 + self._session_total_usd += cost_usd + self._last_call_cost = cost_usd + self._capture_settlement(submit_resp) + data = submit_resp.json() + save_to_cache(endpoint, body, data, cost_usd=cost_usd, **self._billing_meta()) + self._log_transaction(endpoint, body, data, cost_usd) + return data + + if submit_resp.status_code != 202: + try: + error_body = submit_resp.json() + except Exception: + error_body = {"error": "Request failed"} + raise APIError( + f"{label} request failed: {paid_request_error_prefix(submit_resp.headers)}: HTTP {submit_resp.status_code}", + submit_resp.status_code, + sanitize_error_response(error_body), + retry_after=retry_after_of(submit_resp), + ) # Step 4: slow path — poll until completed (or budget exhausted). try: @@ -5166,8 +5324,12 @@ async def _request_image_with_payment( job_id = submit_data.get("id") 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: + poll_url = self._absolute_url(poll_url_rel, job_id) + # Neither rail books spend on completion here: a settled-at-submit + # wallet route booked it above, and the account rail has no x402 charge + # (it reserves credit at accept and settles it when the job completes). + charged_at_submit = settled_at_submit or account_job + if settled_at_submit and not account_job: # 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 @@ -5175,10 +5337,9 @@ async def _request_image_with_payment( 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, - } + poll_headers = {"User-Agent": _get_user_agent()} + if encoded_payment is not None: + poll_headers["PAYMENT-SIGNATURE"] = encoded_payment budget = ( poll_budget_seconds @@ -5269,7 +5430,11 @@ async def _request_image_with_payment( if last_status == "failed": raise APIError( f"{label} failed upstream: {poll_data.get('error', 'unknown')}" - + (" (payment was settled at submit)" if settled_at_submit else ""), + + ( + " (credit reserved at accept is released, not charged)" + if account_job + else " (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), @@ -5283,7 +5448,7 @@ 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 - if not settled_at_submit: + if not charged_at_submit: self._session_calls += 1 self._session_total_usd += cost_usd self._last_call_cost = cost_usd @@ -5311,17 +5476,27 @@ async def _request_image_with_payment( ( ( 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." + f"(last status: {last_status}). Credit was reserved when the job " + "was accepted and is charged only when it completes; the job " + "stays claimable for ~48h — re-poll poll_url with the same API " + "key to fetch the result." ) - if settled_at_submit + if account_job 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." + ( + 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, diff --git a/blockrun_llm/types.py b/blockrun_llm/types.py index bd02f0e..9b4a2c7 100644 --- a/blockrun_llm/types.py +++ b/blockrun_llm/types.py @@ -628,6 +628,8 @@ class VideoClip(BaseModel): duration_seconds: Optional[int] = None request_id: Optional[str] = None # Upstream provider's request id (xAI) backed_up: Optional[bool] = None + last_frame_url: Optional[str] = None + last_frame_backed_up: Optional[bool] = None class VideoResponse(BaseModel): diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py index 0ebf5a5..6f1bece 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -195,6 +195,212 @@ def validate_video_input_type(input_type: str | None) -> None: ) +# Per-model Seedance capabilities. Mirrors the gateway registry and the MCP's +# table (blockrun-mcp src/tools/video.ts), named rather than pattern-matched so +# a guard and its message can never drift apart. +SEEDANCE_25 = "bytedance/seedance-2.5" +SEEDANCE_15_PRO = "bytedance/seedance-1.5-pro" +# Reference IMAGES and the per-model count ceiling the gateway enforces. +SEEDANCE_REFERENCE_IMAGE_LIMIT: dict[str, int] = { + "bytedance/seedance-2.0": 9, + "bytedance/seedance-2.0-fast": 9, + "bytedance/seedance-2.0-mini": 9, + SEEDANCE_25: 30, +} +# Reference VIDEO/AUDIO clips (supportsReferenceMedia). 2.5 takes them since +# 2026-09-26. The gateway bills each clip at its model's ceiling whatever the +# real length (REFERENCE_CEILING_SECONDS in its models.ts): 15.2s on the 2.0 +# family, 30.2s on 2.5. +SEEDANCE_REFERENCE_MEDIA_MODELS = frozenset( + { + "bytedance/seedance-2.0", + "bytedance/seedance-2.0-fast", + "bytedance/seedance-2.0-mini", + SEEDANCE_25, + } +) +# bitrate_mode is a 2.x control: the same four models, named on their own so +# it does not move whenever the clip list does. +SEEDANCE_BITRATE_MODE_MODELS = frozenset( + { + "bytedance/seedance-2.0", + "bytedance/seedance-2.0-fast", + "bytedance/seedance-2.0-mini", + SEEDANCE_25, + } +) +SEEDANCE_MAX_REFERENCE_CLIPS = 3 +SEEDANCE_BITRATE_MODES = ("standard", "high") +SEEDANCE_OUTPUT_FORMATS = ("mp4", "mov") +_REFERENCE_CLIP_KEYS = frozenset({"url", "role"}) + + +def _is_http_url(value: Any) -> bool: + return isinstance(value, str) and value.startswith(("https://", "http://")) + + +def validate_video_request( + model: str, + *, + api_key_mode: bool, + solana_wallet: bool = False, + image_url: str | None = None, + last_frame_url: str | None = None, + reference_image_urls: list[str] | None = None, + reference_videos: list[dict[str, str]] | None = None, + reference_audios: list[dict[str, str]] | None = None, + real_face_asset_id: str | None = None, + bitrate_mode: str | None = None, + output_format: str | None = None, + camera_fixed: bool | None = None, + safety_identifier: str | None = None, +) -> None: + """ + Refuse a video request the gateway would refuse, before anything is sent. + + Shared by ``VideoClient.generate`` (Base) and ``SolanaLLMClient.video`` + (sync and async), so the rails can never disagree about what they accept. + On the wallet rails most of these would come back as a 400 before quoting; + on the account rail there is no quote step at all (the key is billed at + submit), so a refusal here is the only gate that runs before money moves. + + Args: + model: The resolved model id (defaults already applied). + api_key_mode: True when the client pays with a ``brk_`` API key. + solana_wallet: True for the Solana wallet rail (sol.blockrun.ai). + Remaining args mirror the video kwargs; None (or an empty list) omits. + + Raises: + ValueError: On any combination the gateway would refuse. + """ + has_refs = bool(reference_image_urls or reference_videos or reference_audios) + has_clips = bool(reference_videos or reference_audios) + + # The rail fact dominates every other reference guard: both wallet + # gateways answer any reference_* field with a 400 before quoting + # (blockrun#728, blockrun-sol#374), so a per-model message would point the + # caller at a fix that still cannot work. + if has_refs and not api_key_mode: + raise ValueError( + "Reference media (reference_image_urls / reference_videos / " + "reference_audios) is served only by the BlockRun account rail " + "(api.blockrun.ai); the Base and Solana wallet gateways refuse it. " + "Set BLOCKRUN_API_KEY to use the account rail, or use image_url / " + "last_frame_url frame seeding, which works on the wallet rails." + ) + if has_refs and (image_url or last_frame_url or real_face_asset_id): + raise ValueError( + "Reference inputs are mutually exclusive with image_url, last_frame_url, " + "and real_face_asset_id; pass character or style images as " + "reference_image_urls instead." + ) + + if image_url and real_face_asset_id: + raise ValueError( + "image_url and real_face_asset_id are mutually exclusive; pass at most one." + ) + if last_frame_url and not image_url: + raise ValueError( + "last_frame_url requires image_url: image_url seeds the FIRST frame and " + "last_frame_url the FINAL frame — send both." + ) + if last_frame_url and real_face_asset_id: + raise ValueError( + "last_frame_url and real_face_asset_id are mutually exclusive; " + "first-and-last-frame uses image_url + last_frame_url." + ) + # sol.blockrun.ai is a separate deploy that has not taken 2.5 into its + # first-and-last-frame list: it 400s where Base quotes the same body + # (probed 2026-09-29). Same refusal as blockrun-mcp 0.53.1. + if last_frame_url and solana_wallet and model == SEEDANCE_25: + raise ValueError( + f"{SEEDANCE_25} first-and-last-frame (last_frame_url) is not served by the " + "Solana gateway yet. Use it on Base (VideoClient) or the account rail " + "(BLOCKRUN_API_KEY), or pick seedance-1.5-pro / 2.0 / 2.0-fast / 2.0-mini." + ) + + if reference_image_urls: + limit = SEEDANCE_REFERENCE_IMAGE_LIMIT.get(model) + if limit is None: + raise ValueError( + f"Model {model} does not accept reference images (reference_image_urls). " + f"Supported: {', '.join(SEEDANCE_REFERENCE_IMAGE_LIMIT)}." + ) + if len(reference_image_urls) > limit: + raise ValueError( + f"reference_image_urls accepts at most {limit} images on {model}; " + f"got {len(reference_image_urls)}." + ) + if not all(_is_http_url(u) for u in reference_image_urls): + raise ValueError("reference_image_urls must all be http(s) URLs.") + + if has_clips and model not in SEEDANCE_REFERENCE_MEDIA_MODELS: + raise ValueError( + f"Model {model} does not accept reference video or audio clips. " + f"Supported: {', '.join(sorted(SEEDANCE_REFERENCE_MEDIA_MODELS))}." + ) + for field, clips in ( + ("reference_videos", reference_videos), + ("reference_audios", reference_audios), + ): + if not clips: + continue + if len(clips) > SEEDANCE_MAX_REFERENCE_CLIPS: + raise ValueError( + f"{field} accepts at most {SEEDANCE_MAX_REFERENCE_CLIPS} clips; got {len(clips)}." + ) + for clip in clips: + if ( + not isinstance(clip, dict) + or not _is_http_url(clip.get("url")) + or not set(clip) <= _REFERENCE_CLIP_KEYS + or clip.get("role", "reference") != "reference" + ): + raise ValueError( + f"{field} entries must be {{'url': 'https://…'}} with an optional " + "'role': 'reference' and no other keys." + ) + if reference_audios and not (reference_image_urls or reference_videos): + raise ValueError( + "reference_audios requires a reference image or video — combine it with " + "reference_image_urls or reference_videos." + ) + + if bitrate_mode is not None: + if bitrate_mode not in SEEDANCE_BITRATE_MODES: + raise ValueError( + f"bitrate_mode must be one of {', '.join(SEEDANCE_BITRATE_MODES)}; " + f"got {bitrate_mode!r}." + ) + if model not in SEEDANCE_BITRATE_MODE_MODELS: + raise ValueError( + f"bitrate_mode requires a Seedance 2.x model; got {model}. " + f"Supported: {', '.join(sorted(SEEDANCE_BITRATE_MODE_MODELS))}." + ) + if output_format is not None: + if output_format not in SEEDANCE_OUTPUT_FORMATS: + raise ValueError( + f"output_format must be one of {', '.join(SEEDANCE_OUTPUT_FORMATS)}; " + f"got {output_format!r}." + ) + if model != SEEDANCE_25: + raise ValueError( + f"output_format requires {SEEDANCE_25}; got {model}. Every other model returns MP4." + ) + if camera_fixed is not None and model != SEEDANCE_15_PRO: + raise ValueError(f"camera_fixed requires {SEEDANCE_15_PRO}; got {model}.") + if safety_identifier is not None and not model.startswith("bytedance/seedance-"): + raise ValueError(f"safety_identifier requires a Seedance model; got {model}.") + + if real_face_asset_id is not None and not real_face_asset_id.startswith("ta_"): + raise ValueError( + "real_face_asset_id must start with 'ta_' " + "(a Virtual Portrait or RealFace asset id, e.g. 'ta_abc123xyz' — " + "enroll via PortraitClient / POST /v1/portrait/enroll or " + "RealFaceClient / POST /v1/realface/enroll)" + ) + + def validate_image_quality(quality: str | None) -> None: """ Validate the optional `quality` knob on Solana image generation/editing. diff --git a/blockrun_llm/video.py b/blockrun_llm/video.py index 3ec36d5..dca178c 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -45,8 +45,8 @@ payment_mode, raise_for_api_key_402, resolve_api_key, - resolve_poll_url, ) +from .jobs import absolute_poll_url from .types import APIError, PaymentError, VideoResponse, retry_after_of from .validation import ( raise_api_error, @@ -54,6 +54,7 @@ validate_api_url, validate_private_key, validate_video_input_type, + validate_video_request, ) from .x402 import create_payment_payload, extract_payment_details, parse_payment_required @@ -165,6 +166,12 @@ def generate( image_url: str | None = None, last_frame_url: str | None = None, reference_image_urls: list[str] | None = None, + reference_videos: list[dict[str, str]] | None = None, + reference_audios: list[dict[str, str]] | None = None, + bitrate_mode: str | None = None, + output_format: str | None = None, + camera_fixed: bool | None = None, + safety_identifier: str | None = None, real_face_asset_id: str | None = None, duration_seconds: int | None = None, aspect_ratio: str | None = None, @@ -192,13 +199,31 @@ def generate( last_frame_url: First-and-last-frame interpolation — a second image that seeds the FINAL frame so the model tweens from `image_url` -> `last_frame_url`. Requires `image_url` and a - Seedance model (bytedance/seedance-1.5-pro, seedance-2.0, - or seedance-2.0-fast). Priced identically to image-to-video. - reference_image_urls: Omni / multi-reference — up to 9 reference - image URLs for character/style consistency (Seedance 2.0 - only). Cite them as "image 1", "image 2" in the prompt. - Mutually exclusive with `image_url`, `last_frame_url`, and - `real_face_asset_id`. + Seedance model (1.5-pro, 2.0, 2.0-fast, 2.0-mini, 2.5). + Priced identically to image-to-video. + reference_image_urls: Omni / multi-reference images for + character/style consistency — up to 9 on seedance-2.0 / + 2.0-fast / 2.0-mini, up to 30 on seedance-2.5. Cite them as + "image 1", "image 2" in the prompt. **Account rail only** + (BLOCKRUN_API_KEY): the wallet gateways refuse every + reference field. Mutually exclusive with `image_url`, + `last_frame_url`, and `real_face_asset_id`. + reference_videos: Up to 3 motion references, each + ``{"url": "https://…"}`` (optional ``"role": "reference"``), + on seedance-2.0 / 2.0-fast / 2.0-mini / 2.5. Account rail + only. **Cost:** every reference clip is billed at the model's + reference ceiling whatever its real length (15.2s on the 2.0 + family, 30.2s on 2.5), so one clip can multiply the price of a + short render several times. + reference_audios: Up to 3 audio references, same shape and models + as `reference_videos`, billed the same way (at a lower + per-second rate). Requires a reference image or video. + bitrate_mode: `"standard"` or `"high"` (Seedance 2.0 / 2.0-fast / + 2.0-mini / 2.5). + output_format: `"mp4"` or `"mov"` (seedance-2.5 only). + camera_fixed: Lock the camera (seedance-1.5-pro only). + safety_identifier: End-user identifier forwarded to the provider + for abuse attribution (Seedance only). real_face_asset_id: A `ta_xxxxxx` face/character asset for identity consistency — either a Virtual Portrait (AI character, via `PortraitClient`, $0.01) or a RealFace @@ -235,45 +260,33 @@ def generate( Raises: ValueError: If mutually-exclusive image inputs are combined (see above), `last_frame_url` is passed without `image_url`, + a Seedance-specific field is sent to a model that does not + take it, reference media is sent on the wallet rail, `real_face_asset_id` is malformed, or `input_type` is not one - of the four accepted values. + of the four accepted values. Raised before any request. PaymentError: If wallet balance is insufficient. APIError: If upstream fails, the job times out, or any transport error occurs. """ - if image_url and real_face_asset_id: - raise ValueError( - "image_url and real_face_asset_id are mutually exclusive; pass at most one." - ) - if last_frame_url and not image_url: - raise ValueError( - "last_frame_url requires image_url: image_url seeds the FIRST frame and " - "last_frame_url the FINAL frame — send both." - ) - if last_frame_url and real_face_asset_id: - raise ValueError( - "last_frame_url and real_face_asset_id are mutually exclusive; " - "first-and-last-frame uses image_url + last_frame_url." - ) - if reference_image_urls: - if image_url or last_frame_url or real_face_asset_id: - raise ValueError( - "reference_image_urls is mutually exclusive with image_url, " - "last_frame_url, and real_face_asset_id." - ) - if len(reference_image_urls) > 9: - raise ValueError("reference_image_urls accepts at most 9 images.") - if real_face_asset_id is not None and not real_face_asset_id.startswith("ta_"): - raise ValueError( - "real_face_asset_id must start with 'ta_' " - "(a Virtual Portrait or RealFace asset id, e.g. 'ta_abc123xyz' — " - "enroll via PortraitClient / POST /v1/portrait/enroll or " - "RealFaceClient / POST /v1/realface/enroll)" - ) + resolved_model = model or self.DEFAULT_MODEL + validate_video_request( + resolved_model, + api_key_mode=bool(self.api_key), + image_url=image_url, + last_frame_url=last_frame_url, + reference_image_urls=reference_image_urls, + reference_videos=reference_videos, + reference_audios=reference_audios, + real_face_asset_id=real_face_asset_id, + bitrate_mode=bitrate_mode, + output_format=output_format, + camera_fixed=camera_fixed, + safety_identifier=safety_identifier, + ) validate_video_input_type(input_type) body: dict[str, Any] = { - "model": model or self.DEFAULT_MODEL, + "model": resolved_model, "prompt": prompt, } if image_url: @@ -282,6 +295,18 @@ def generate( body["last_frame_url"] = last_frame_url if reference_image_urls: body["reference_image_urls"] = reference_image_urls + if reference_videos: + body["reference_videos"] = reference_videos + if reference_audios: + body["reference_audios"] = reference_audios + if bitrate_mode is not None: + body["bitrate_mode"] = bitrate_mode + if output_format is not None: + body["output_format"] = output_format + if camera_fixed is not None: + body["camera_fixed"] = camera_fixed + if safety_identifier is not None: + body["safety_identifier"] = safety_identifier if real_face_asset_id: body["real_face_asset_id"] = real_face_asset_id if duration_seconds is not None: @@ -416,7 +441,7 @@ def _submit_and_poll( retry_after=retry_after_of(submit_resp), ) - poll_url = self._absolute(poll_url_rel) + poll_url = absolute_poll_url(poll_url_rel, self.api_url, self.api_key, job_id) # Step 3: poll with the same PAYMENT-SIGNATURE until completed. The # signed authorization is valid for MAX_TIMEOUT_SECONDS (600s); when a @@ -526,12 +551,6 @@ def _sign_from_challenge(self, resp402: httpx.Response, fallback_url: str) -> st extensions=extensions, ) - def _absolute(self, url: str) -> str: - if url.startswith(("http://", "https://")): - return url - # self.api_url already ends without '/'; poll_url starts with '/api/...' - return resolve_poll_url(url, self.api_url, self.api_key) - def _extract_payment_required(self, resp: httpx.Response) -> dict[str, Any]: header = resp.headers.get("payment-required") if header: diff --git a/docs/seedance-capabilities.md b/docs/seedance-capabilities.md new file mode 100644 index 0000000..30cba8c --- /dev/null +++ b/docs/seedance-capabilities.md @@ -0,0 +1,60 @@ +# Seedance capabilities in the Python SDK + +What `VideoClient.generate` (Base) and `SolanaLLMClient.video` / +`AsyncSolanaLLMClient.video` accept, per model and per rail. The SDK refuses +every combination below that the gateway would refuse, locally and before any +request is sent. On the account rail this is the only check that runs before +money moves, because there is no quote step there. The guards live in +`blockrun_llm/validation.py` (`validate_video_request`). They mirror the MCP's +table in `blockrun-mcp/src/tools/video.ts`, and both should change together. + +## Per model + +| Model | First + last frame | Reference images | Reference video / audio clips | +| --- | --- | --- | --- | +| seedance-1.5-pro | Yes | No | No | +| seedance-2.0 / 2.0-fast / 2.0-mini | Yes | 1–9 | 1–3 of each | +| seedance-2.5 | Yes (not on the Solana wallet gateway yet) | 1–30 | 1–3 of each | +| grok-imagine-video, sora-2 | No | No | No | + +- Reference audio needs at least one reference image or video in the same request. +- Frame seeds (`image_url`, `last_frame_url`, `real_face_asset_id`) and + reference inputs are mutually exclusive. +- Each clip is `{"url": "https://…"}`, optionally with `"role": "reference"`. + No other keys are allowed. + +## Per rail + +| Rail | Reference media | seedance-2.5 last frame | +| --- | --- | --- | +| Account (`BLOCKRUN_API_KEY`, api.blockrun.ai) | Yes | Yes | +| Base wallet (blockrun.ai) | Refused (gateway 400s before quoting) | Yes | +| Solana wallet (sol.blockrun.ai) | Refused (gateway 400s before quoting) | Refused (gateway 400s before quoting) | + +On the account rail, credit is reserved when the job is accepted and charged +once, when the job completes; a failed job releases it. A job that times out in +the SDK still holds that reservation and is charged if it completes. It stays +claimable for about 48h via the `poll_url` in the error, with the same API key. + +## Cost of reference clips + +Reference video and audio are billed per reference second, at the model's +reference ceiling, whatever the clip's real length: 15.2s on seedance-2.0 / +2.0-fast / 2.0-mini, 30.2s on seedance-2.5. The gateway only sees URLs, never +durations. One clip on a 5s 720p seedance-2.0-mini render is +roughly 4x the price of the render alone. At 4K with three of each type the +price runs into the hundreds of dollars. Audio seconds bill at about 0.3x the +video rate. + +## Output controls + +| Field | Models | Values | +| --- | --- | --- | +| `bitrate_mode` | seedance-2.0 / 2.0-fast / 2.0-mini / 2.5 | `standard`, `high` | +| `output_format` | seedance-2.5 | `mp4`, `mov` | +| `camera_fixed` | seedance-1.5-pro | bool | +| `safety_identifier` | any Seedance | str | +| `return_last_frame` | any Seedance | bool | + +When the upstream returns a last frame, `data[0].last_frame_url` and +`data[0].last_frame_backed_up` carry it. diff --git a/tests/unit/test_music_poll.py b/tests/unit/test_music_poll.py index 1544410..1227773 100644 --- a/tests/unit/test_music_poll.py +++ b/tests/unit/test_music_poll.py @@ -183,6 +183,29 @@ def handler(request: httpx.Request) -> httpx.Response: assert "no payment was taken" in str(excinfo.value).lower() +def test_music_api_key_timeout_says_credit_is_reserved(monkeypatch: pytest.MonkeyPatch) -> None: + # The account rail reserves credit at accept and charges on completion, so + # "no payment was taken" would mislead: the reservation still stands. + monkeypatch.setattr(MusicClient, "MUSIC_POLL_INTERVAL_SECONDS", 0.0) + monkeypatch.setattr(MusicClient, "MUSIC_POLL_BUDGET_SECONDS", 0.05) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return _queued("mus_5") + return httpx.Response( + 202, + headers={"content-type": "application/json"}, + json={"id": "mus_5", "status": "in_progress"}, + ) + + with pytest.raises(APIError) as excinfo: + _apikey_client(httpx.MockTransport(handler), monkeypatch).generate("forever") + message = str(excinfo.value).lower() + assert excinfo.value.status_code == 504 + assert "credit was reserved" in message + assert "no payment was taken" not in message + + def test_music_fast_path_unchanged() -> None: # A track that finishes inline still comes back as the legacy 200 shape. def handler(request: httpx.Request) -> httpx.Response: diff --git a/tests/unit/test_poll_origin_pin.py b/tests/unit/test_poll_origin_pin.py new file mode 100644 index 0000000..ddea4f0 --- /dev/null +++ b/tests/unit/test_poll_origin_pin.py @@ -0,0 +1,118 @@ +"""An API key must never follow a server-supplied ``poll_url`` off-origin. + +Every poll on the account rail carries ``Authorization: Bearer brk_...``. +``VideoClient`` and the shared ``jobs.py`` poller (images, music) used to return +an absolute ``poll_url`` as-is, before ``resolve_poll_url`` could pin it to the +gateway's origin, so a response naming ``https://evil.example/...`` received +the key. Absolute poll URLs now go through the same pin as relative ones; a +refusal is an ``APIError`` carrying the accepted job's id and the refused URL. + +``httpx.MockTransport`` keeps the network out; any request to a foreign host +is recorded so the tests can assert none was made. +""" + +from __future__ import annotations + +import httpx +import pytest + +from blockrun_llm import MusicClient, VideoClient +from blockrun_llm.apikey import DEFAULT_API_KEY_URL +from blockrun_llm.types import APIError + +KEY = "brk_live_poll_origin_fixture" +FOREIGN = "https://evil.example/v1/jobs/J1" + + +def _handler(poll_url: str, done: dict, seen: list[httpx.Request]): + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + if request.url.host != httpx.URL(DEFAULT_API_KEY_URL).host: + return httpx.Response(200, json=done) + if request.method == "POST": + return httpx.Response(202, json={"id": "J1", "poll_url": poll_url, "status": "queued"}) + return httpx.Response(200, json=done) + + return handler + + +_VIDEO_DONE = { + "status": "completed", + "created": 1, + "model": "bytedance/seedance-2.0", + "data": [{"url": "https://cdn/v.mp4"}], +} +_MUSIC_DONE = { + "id": "J1", + "status": "completed", + "created": 1, + "model": "minimax/music-2.5+", + "data": [{"url": "https://cdn/t.mp3"}], +} + + +def _video(handler, monkeypatch: pytest.MonkeyPatch) -> VideoClient: + monkeypatch.setattr(VideoClient, "POLL_INTERVAL_SECONDS", 0.0) + client = VideoClient(private_key=KEY) + client._client = httpx.Client( + transport=httpx.MockTransport(handler), headers=client._client.headers + ) + return client + + +def _music(handler, monkeypatch: pytest.MonkeyPatch) -> MusicClient: + monkeypatch.setattr(MusicClient, "MUSIC_POLL_INTERVAL_SECONDS", 0.0) + client = MusicClient(private_key=KEY) + client._client = httpx.Client( + transport=httpx.MockTransport(handler), headers=client._client.headers + ) + return client + + +@pytest.mark.parametrize( + "poll_url", + [FOREIGN, "http://api.blockrun.ai/v1/jobs/J1", "//evil.example/v1/jobs/J1"], +) +def test_video_never_sends_the_key_to_a_foreign_poll_origin(monkeypatch, poll_url): + seen: list[httpx.Request] = [] + client = _video(_handler(poll_url, _VIDEO_DONE, seen), monkeypatch) + with pytest.raises(APIError, match="different polling origin") as exc: + client.generate("x", model="bytedance/seedance-2.0") + assert [r.method for r in seen] == ["POST"] + assert exc.value.response == {"id": "J1", "poll_url": poll_url} + + +def test_video_same_origin_absolute_poll_url_still_polls(monkeypatch): + seen: list[httpx.Request] = [] + same = f"{DEFAULT_API_KEY_URL}/v1/videos/generations/J1?token=a" + client = _video(_handler(same, _VIDEO_DONE, seen), monkeypatch) + resp = client.generate("x", model="bytedance/seedance-2.0") + assert resp.data[0].url == "https://cdn/v.mp4" + assert [r.method for r in seen] == ["POST", "GET"] + assert str(seen[1].url) == same + assert seen[1].headers["authorization"] == f"Bearer {KEY}" + + +def test_music_never_sends_the_key_to_a_foreign_poll_origin(monkeypatch): + seen: list[httpx.Request] = [] + client = _music(_handler(FOREIGN, _MUSIC_DONE, seen), monkeypatch) + with pytest.raises(APIError, match="different polling origin") as exc: + client.generate("x") + assert [r.method for r in seen] == ["POST"] + assert exc.value.response == {"id": "J1", "poll_url": FOREIGN} + + +def test_music_same_origin_absolute_poll_url_still_polls(monkeypatch): + seen: list[httpx.Request] = [] + same = f"{DEFAULT_API_KEY_URL}/v1/audio/generations/J1" + client = _music(_handler(same, _MUSIC_DONE, seen), monkeypatch) + assert client.generate("x").data[0].url == "https://cdn/t.mp3" + assert [str(r.url) for r in seen[1:]] == [same] + + +def test_wallet_rail_absolute_poll_url_is_unchanged(): + # No key, nothing to leak: the wallet rail keeps taking an absolute + # poll_url as given (its signature is bound to the job, not the host). + from blockrun_llm.jobs import absolute_poll_url + + assert absolute_poll_url(FOREIGN, "https://blockrun.ai/api", None) == FOREIGN diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index fadcb3b..c6d48be 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -662,6 +662,12 @@ def test_input_type_reaches_body(self) -> None: image_url=None, last_frame_url=None, reference_image_urls=None, + reference_videos=None, + reference_audios=None, + bitrate_mode=None, + output_format=None, + camera_fixed=None, + safety_identifier=None, real_face_asset_id=None, duration_seconds=None, aspect_ratio=None, @@ -671,6 +677,7 @@ def test_input_type_reaches_body(self) -> None: watermark=None, return_last_frame=None, input_type="text", + api_key_mode=False, ) assert body["input_type"] == "text" @@ -799,3 +806,375 @@ async def test_async_failed_job_is_booked(self) -> None: assert client._session_calls == 1 finally: await client._client.aclose() + + +# --------------------------------------------------------------------------- +# Account rail (brk_ key). The first POST carries the key, so it IS the billed +# submit: a 202 there is the accepted job, which must be polled — not handed +# back as the result (that crashed VideoResponse after the charge). Reference +# media is account-rail only, so this is the path every reference job takes. +# --------------------------------------------------------------------------- + +_ACCOUNT_KEY = "brk_live_solana_video_fixture" +_SEEDANCE_DONE = { + "status": "completed", + "created": 1, + "model": "bytedance/seedance-2.0", + "data": [{"url": "https://cdn/v.mp4", "last_frame_url": "https://cdn/last.png"}], +} + + +def _account_handler(posts: list[httpx.Request], polls: list[httpx.Request], *, first: int = 202): + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + posts.append(request) + if first != 202: + return httpx.Response(first, json={"error": "upstream"}) + return httpx.Response( + 202, + json={"id": "V1", "poll_url": "/api/v1/videos/generations/V1", "status": "queued"}, + ) + polls.append(request) + if len(polls) == 1: + return httpx.Response(202, json={"status": "in_progress"}) + return httpx.Response(200, json=_SEEDANCE_DONE) + + return handler + + +def _account_client(handler: Any) -> SolanaLLMClient: + client = SolanaLLMClient(private_key=_ACCOUNT_KEY) + client._client = httpx.Client( + transport=httpx.MockTransport(handler), headers={"Authorization": f"Bearer {_ACCOUNT_KEY}"} + ) + return client + + +def _async_account_client(handler: Any) -> AsyncSolanaLLMClient: + client = AsyncSolanaLLMClient(private_key=_ACCOUNT_KEY) + client._client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), headers={"Authorization": f"Bearer {_ACCOUNT_KEY}"} + ) + return client + + +_REFS = { + "model": "bytedance/seedance-2.0", + "reference_image_urls": ["https://example.com/person.png"], + "reference_videos": [{"url": "https://example.com/motion.mp4"}], + "reference_audios": [{"url": "https://example.com/music.mp3"}], + "bitrate_mode": "high", + "safety_identifier": "test", + "return_last_frame": True, +} + + +class TestSolanaAccountRailVideo: + @pytest.fixture(autouse=True) + def _fast_polls(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SolanaLLMClient, "VIDEO_POLL_INTERVAL_SECONDS", 0.001) + + def test_accepted_job_is_polled_to_completion_with_one_post(self) -> None: + import json + + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _account_client(_account_handler(posts, polls)) + resp = client.video("follow the motion", **_REFS) + assert resp.data[0].url == "https://cdn/v.mp4" + assert resp.data[0].last_frame_url == "https://cdn/last.png" + assert len(posts) == 1 + assert len(polls) == 2 + assert all("PAYMENT-SIGNATURE" not in r.headers for r in posts + polls) + body = json.loads(posts[0].content) + assert body["reference_videos"] == [{"url": "https://example.com/motion.mp4"}] + assert body["reference_audios"] == [{"url": "https://example.com/music.mp3"}] + assert body["bitrate_mode"] == "high" + + @pytest.mark.parametrize("status", [502, 503]) + def test_billed_submit_is_never_replayed_on_5xx(self, status: int) -> None: + posts: list[httpx.Request] = [] + client = _account_client(_account_handler(posts, [], first=status)) + with pytest.raises(APIError): + client.video("x", model="bytedance/seedance-2.0") + assert len(posts) == 1 + + def test_timeout_says_credit_is_reserved_until_completion( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(SolanaLLMClient, "VIDEO_POLL_BUDGET_SECONDS", 0.0) + client = _account_client(_account_handler([], [])) + with pytest.raises(APIError, match="reserved when the job was accepted") as exc: + client.video("x", model="bytedance/seedance-2.0") + assert exc.value.response["id"] == "V1" + + def test_failed_job_says_the_credit_hold_is_released(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response( + 202, + json={"id": "V1", "poll_url": "/api/v1/videos/generations/V1"}, + ) + return httpx.Response(200, json={"status": "failed", "error": "upstream refused"}) + + client = _account_client(handler) + with pytest.raises(APIError) as exc: + client.video("x", model="bytedance/seedance-2.0") + assert "released, not charged" in str(exc.value) + assert "settled at submit" not in str(exc.value) + + async def test_async_accepted_job_is_polled_to_completion(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _async_account_client(_account_handler(posts, polls)) + try: + resp = await client.video("follow the motion", **_REFS) + assert resp.data[0].url == "https://cdn/v.mp4" + assert len(posts) == 1 + assert all("PAYMENT-SIGNATURE" not in r.headers for r in posts + polls) + finally: + await client._client.aclose() + + +class TestSolanaWalletVideoGuards: + def test_reference_media_refused_before_any_request(self) -> None: + calls: list[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _VIDEO_OK)) + with pytest.raises(ValueError, match="account rail"): + client.video("x", **_REFS) + assert calls == [] + + async def test_async_reference_media_refused_before_any_request(self) -> None: + calls: list[httpx.Request] = [] + client = _make_async_client(_paid_flow(calls, _VIDEO_OK)) + try: + with pytest.raises(ValueError, match="account rail"): + await client.video("x", **_REFS) + assert calls == [] + finally: + await client._client.aclose() + + def test_seedance_25_last_frame_refused_on_solana_wallet(self) -> None: + calls: list[httpx.Request] = [] + client = _make_client(_paid_flow(calls, _VIDEO_OK)) + with pytest.raises(ValueError, match="not served by the Solana gateway"): + client.video( + "x", + model="bytedance/seedance-2.5", + image_url="https://example.com/a.png", + last_frame_url="https://example.com/b.png", + ) + assert calls == [] + + +# --------------------------------------------------------------------------- +# Account rail audio (music / speech / sound effects). Same contract as video +# above: the keyed first POST is the billed submit, so it is never replayed on +# a 5xx, and a 202 is the accepted job — polled unsigned, never returned as the +# result (that crashed MusicResponse after the charge). +# --------------------------------------------------------------------------- + +_MUSIC_DONE = { + "id": "M1", + "status": "completed", + "created": 1, + "model": "minimax/music-2.5+", + "data": [{"url": "https://cdn/x.mp3"}], +} + + +def _audio_account_handler( + posts: list[httpx.Request], + polls: list[httpx.Request], + *, + first: int = 202, + done: dict[str, Any] | None = None, + poll_url: str = "/api/v1/audio/generations/M1", +): + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + posts.append(request) + if first == 200: + return httpx.Response(200, json=done) + if first != 202: + return httpx.Response(first, json={"error": "upstream"}) + return httpx.Response(202, json={"id": "M1", "poll_url": poll_url, "status": "queued"}) + polls.append(request) + if len(polls) == 1: + return httpx.Response(202, json={"status": "in_progress"}) + return httpx.Response(200, json=done or _MUSIC_DONE) + + return handler + + +_AUDIO_CALLS = [ + ("music", ("lo-fi beats",), "/v1/audio/generations"), + ("speech", ("hello world",), "/v1/audio/speech"), + ("sound_effect", ("thunder clap",), "/v1/audio/sound-effects"), +] + + +class TestSolanaAccountRailAudio: + @pytest.fixture(autouse=True) + def _fast_polls(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SolanaLLMClient, "AUDIO_POLL_INTERVAL_SECONDS", 0.001) + + def test_music_202_is_polled_to_completion_with_one_post(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _account_client(_audio_account_handler(posts, polls)) + resp = client.music("lo-fi beats") + assert isinstance(resp, MusicResponse) + assert resp.data[0].url == "https://cdn/x.mp3" + assert len(posts) == 1 + assert len(polls) == 2 + # api.blockrun.ai serves the gateway's /api/v1/... poll route at /v1/... + assert polls[0].url.path == "/v1/audio/generations/M1" + assert all("PAYMENT-SIGNATURE" not in r.headers for r in posts + polls) + assert polls[0].headers["authorization"] == f"Bearer {_ACCOUNT_KEY}" + + def test_speech_202_is_polled_to_completion(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _account_client( + _audio_account_handler(posts, polls, done={**_SPEECH_OK, "status": "completed"}) + ) + resp = client.speech("hello world") + assert isinstance(resp, SpeechResponse) + assert resp.data[0].url == "https://cdn/x.wav" + assert len(posts) == 1 + + def test_inline_200_is_returned_as_is(self) -> None: + posts: list[httpx.Request] = [] + client = _account_client(_audio_account_handler(posts, [], first=200, done=_SPEECH_OK)) + assert client.speech("hello world").data[0].url == "https://cdn/x.wav" + assert len(posts) == 1 + + @pytest.mark.parametrize("method, args, path", _AUDIO_CALLS) + @pytest.mark.parametrize("status", [502, 503]) + def test_billed_submit_is_never_replayed_on_5xx( + self, method: str, args: tuple, path: str, status: int + ) -> None: + posts: list[httpx.Request] = [] + client = _account_client(_audio_account_handler(posts, [], first=status)) + with pytest.raises(APIError): + getattr(client, method)(*args) + assert len(posts) == 1 + assert posts[0].url.path == path + + def test_timeout_carries_the_poll_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SolanaLLMClient, "AUDIO_POLL_BUDGET_SECONDS", 0.0) + client = _account_client(_audio_account_handler([], [])) + with pytest.raises(APIError, match="did not complete") as exc: + client.music("x") + assert exc.value.response["id"] == "M1" + assert exc.value.response["poll_url"].endswith("/v1/audio/generations/M1") + + def test_foreign_poll_origin_is_refused_without_a_request(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _account_client( + _audio_account_handler(posts, polls, poll_url="https://evil.example/x") + ) + with pytest.raises(APIError, match="different polling origin") as exc: + client.music("x") + assert polls == [] + assert exc.value.response == {"id": "M1", "poll_url": "https://evil.example/x"} + + def test_wallet_rail_still_replays_the_unsigned_probe(self) -> None: + # Wallet rail unchanged: the unsigned probe is not billed, so a 5xx on + # it is still retried once before the 402 is answered. + probes: list[httpx.Request] = [] + calls: list[httpx.Request] = [] + paid = _paid_flow(calls, _MUSIC_OK) + + def handler(request: httpx.Request) -> httpx.Response: + if "PAYMENT-SIGNATURE" not in request.headers: + probes.append(request) + if len(probes) == 1: + return httpx.Response(502, json={"error": "upstream"}) + return paid(request) + + with mock.patch("time.sleep"): + resp = _make_client(handler).music("lo-fi beats") + assert resp.data[0].url == "https://cdn/x.mp3" + assert len(probes) == 2 + assert len(calls) == 1 + + @pytest.mark.parametrize("method, args, path", _AUDIO_CALLS) + async def test_async_billed_submit_is_never_replayed_on_5xx( + self, method: str, args: tuple, path: str + ) -> None: + posts: list[httpx.Request] = [] + client = _async_account_client(_audio_account_handler(posts, [], first=502)) + try: + with pytest.raises(APIError): + await getattr(client, method)(*args) + assert len(posts) == 1 + finally: + await client._client.aclose() + + async def test_async_music_202_is_polled_to_completion(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _async_account_client(_audio_account_handler(posts, polls)) + try: + resp = await client.music("lo-fi beats") + assert isinstance(resp, MusicResponse) + assert len(posts) == 1 + assert len(polls) == 2 + assert all("PAYMENT-SIGNATURE" not in r.headers for r in posts + polls) + finally: + await client._client.aclose() + + async def test_async_speech_202_is_polled_to_completion(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _async_account_client( + _audio_account_handler(posts, polls, done={**_SPEECH_OK, "status": "completed"}) + ) + try: + resp = await client.speech("hello world") + assert isinstance(resp, SpeechResponse) + assert len(posts) == 1 + finally: + await client._client.aclose() + + +class TestSolanaAccountRailImageAndVideo: + @pytest.fixture(autouse=True) + def _fast_polls(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SolanaLLMClient, "IMAGE_POLL_INTERVAL_SECONDS", 0.001) + monkeypatch.setattr(SolanaLLMClient, "VIDEO_POLL_INTERVAL_SECONDS", 0.001) + + def test_image_202_is_polled_to_completion_with_one_post(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + done = {"status": "completed", "created": 1, "data": [{"url": "https://cdn/i.png"}]} + client = _account_client(_audio_account_handler(posts, polls, done=done)) + resp = client.image("a cat", model="openai/gpt-image-2") + assert resp.data[0].url == "https://cdn/i.png" + assert len(posts) == 1 + assert len(polls) == 2 + assert all("PAYMENT-SIGNATURE" not in r.headers for r in posts + polls) + + @pytest.mark.parametrize("status", [502, 503]) + async def test_async_video_billed_submit_is_never_replayed_on_5xx(self, status: int) -> None: + posts: list[httpx.Request] = [] + client = _async_account_client(_account_handler(posts, [], first=status)) + try: + with pytest.raises(APIError): + await client.video("x", model="bytedance/seedance-2.0") + assert len(posts) == 1 + finally: + await client._client.aclose() + + def test_video_foreign_poll_origin_is_refused_without_a_request(self) -> None: + posts: list[httpx.Request] = [] + polls: list[httpx.Request] = [] + client = _account_client( + _audio_account_handler(posts, polls, poll_url="https://evil.example/x") + ) + with pytest.raises(APIError, match="different polling origin"): + client.video("x", model="bytedance/seedance-2.0") + assert polls == [] diff --git a/tests/unit/test_video_params.py b/tests/unit/test_video_params.py index 4cd3d6e..e6c48a2 100644 --- a/tests/unit/test_video_params.py +++ b/tests/unit/test_video_params.py @@ -17,7 +17,13 @@ def client(): @pytest.fixture -def captured(client, monkeypatch): +def account(): + # Reference media is an account-rail capability: both wallet gateways + # refuse every reference_* field before quoting (blockrun#728). + return VideoClient(private_key="brk_live_video_params_fixture") + + +def _capture(client, monkeypatch): captured = {} def fake_submit(body, budget_seconds): @@ -29,6 +35,16 @@ def fake_submit(body, budget_seconds): return captured +@pytest.fixture +def captured(client, monkeypatch): + return _capture(client, monkeypatch) + + +@pytest.fixture +def account_captured(account, monkeypatch): + return _capture(account, monkeypatch) + + def test_first_last_frame_body(client, captured): client.generate( "the flower blooms", @@ -40,15 +56,25 @@ def test_first_last_frame_body(client, captured): assert captured["body"]["last_frame_url"] == "https://example.com/bloom.jpg" -def test_reference_images_body(client, captured): +def test_reference_images_body(account, account_captured): urls = ["https://example.com/1.jpg", "https://example.com/2.jpg"] - client.generate( + account.generate( "the character from image 1 in the city from image 2", model="bytedance/seedance-2.0", reference_image_urls=urls, ) - assert captured["body"]["reference_image_urls"] == urls - assert "image_url" not in captured["body"] + assert account_captured["body"]["reference_image_urls"] == urls + assert "image_url" not in account_captured["body"] + + +def test_reference_media_refused_on_the_wallet_rail(client, captured): + for refs in ( + {"reference_image_urls": ["https://example.com/r.jpg"]}, + {"reference_videos": [{"url": "https://example.com/m.mp4"}]}, + ): + with pytest.raises(ValueError, match="account rail"): + client.generate("x", model="bytedance/seedance-2.0", **refs) + assert captured == {} def test_token360_passthroughs(client, captured): @@ -82,25 +108,28 @@ def test_last_frame_excludes_real_face(client): ) -def test_reference_images_exclude_other_image_inputs(client): +def test_reference_images_exclude_other_image_inputs(account): with pytest.raises(ValueError, match="mutually exclusive"): - client.generate( + account.generate( "x", + model="bytedance/seedance-2.0", image_url="https://example.com/seed.jpg", reference_image_urls=["https://example.com/r.jpg"], ) with pytest.raises(ValueError, match="mutually exclusive"): - client.generate( + account.generate( "x", + model="bytedance/seedance-2.0", real_face_asset_id="ta_abc123", reference_image_urls=["https://example.com/r.jpg"], ) -def test_reference_images_max_nine(client): +def test_reference_images_max_nine(account): with pytest.raises(ValueError, match="at most 9"): - client.generate( + account.generate( "x", + model="bytedance/seedance-2.0", reference_image_urls=[f"https://example.com/{i}.jpg" for i in range(10)], ) @@ -155,3 +184,182 @@ def test_input_type_mismatch_is_left_to_the_gateway(client, captured): """ client.generate("x", input_type="image") # no image_url — gateway's call assert captured["body"]["input_type"] == "image" + + +def test_mixed_references_and_controls_reach_body(account, account_captured): + account.generate( + "follow the motion", + model="bytedance/seedance-2.0", + reference_image_urls=["https://example.com/person.png"], + reference_videos=[{"url": "https://example.com/motion.mp4"}], + reference_audios=[{"url": "https://example.com/music.mp3"}], + bitrate_mode="high", + safety_identifier="test", + return_last_frame=True, + input_type="reference", + ) + body = account_captured["body"] + assert body["reference_videos"] == [{"url": "https://example.com/motion.mp4"}] + assert body["reference_audios"] == [{"url": "https://example.com/music.mp3"}] + assert body["reference_image_urls"] == ["https://example.com/person.png"] + assert body["bitrate_mode"] == "high" + assert body["safety_identifier"] == "test" + assert body["input_type"] == "reference" + + +def test_25_reference_limit_and_output_controls(account, account_captured): + images = ["https://example.com/person.png"] * 30 + account.generate( + "test", model="bytedance/seedance-2.5", reference_image_urls=images, output_format="mov" + ) + assert account_captured["body"]["reference_image_urls"] == images + assert account_captured["body"]["output_format"] == "mov" + with pytest.raises(ValueError, match="at most 30"): + account.generate( + "test", model="bytedance/seedance-2.5", reference_image_urls=images + images + ) + account.generate("test", model="bytedance/seedance-1.5-pro", camera_fixed=False) + assert account_captured["body"]["camera_fixed"] is False + + +def test_reference_media_cannot_be_frame_seeds(account): + with pytest.raises(ValueError, match="mutually exclusive"): + account.generate( + "test", + model="bytedance/seedance-2.0", + image_url="https://example.com/frame.png", + reference_videos=[{"url": "https://example.com/motion.mp4"}], + ) + + +# Per-model guards — mirror the MCP's capability table (blockrun-mcp +# src/tools/video.ts). On the account rail there is no quote step, so these +# refusals are the only thing standing between a bad request and a charge. +_CLIP = [{"url": "https://example.com/motion.mp4"}] + + +@pytest.mark.parametrize( + "model, kwargs, message", + [ + # 1.5-pro takes no reference clips at all + ( + "bytedance/seedance-1.5-pro", + {"reference_videos": _CLIP}, + "does not accept reference video or audio", + ), + ( + "bytedance/seedance-1.5-pro", + {"reference_image_urls": ["https://e/x.png"], "reference_audios": _CLIP}, + "does not accept reference", + ), + ( + "xai/grok-imagine-video", + {"reference_videos": _CLIP}, + "does not accept reference video or audio", + ), + # 2.5 keeps the rest of the clip rules + ("bytedance/seedance-2.5", {"reference_audios": _CLIP}, "requires a reference image"), + ("bytedance/seedance-2.5", {"reference_videos": _CLIP * 4}, "at most 3 clips"), + ( + "bytedance/seedance-2.5", + {"reference_image_urls": ["https://e/x.png"] * 31}, + "at most 30 images", + ), + ( + "bytedance/seedance-1.5-pro", + {"reference_image_urls": ["https://e/x.png"]}, + "does not accept reference images", + ), + ( + "xai/grok-imagine-video", + {"reference_image_urls": ["https://e/x.png"]}, + "does not accept reference images", + ), + ( + "bytedance/seedance-2.0", + {"reference_audios": _CLIP}, + "requires a reference image or video", + ), + ("bytedance/seedance-2.0", {"reference_videos": _CLIP * 4}, "at most 3 clips"), + ( + "bytedance/seedance-2.0", + {"reference_videos": [{"url": "ftp://e/x.mp4"}]}, + "entries must be", + ), + ( + "bytedance/seedance-2.0", + {"reference_videos": [{"url": "https://e/x.mp4", "start": 3}]}, + "no other keys", + ), + ( + "bytedance/seedance-2.0", + {"reference_image_urls": ["data:image/png;base64,AA=="]}, + "http\\(s\\) URLs", + ), + ("bytedance/seedance-1.5-pro", {"bitrate_mode": "high"}, "requires a Seedance 2.x"), + ("bytedance/seedance-2.0", {"bitrate_mode": "ultra"}, "bitrate_mode must be one of"), + ("bytedance/seedance-2.0", {"output_format": "mov"}, "requires bytedance/seedance-2.5"), + ("bytedance/seedance-2.5", {"output_format": "webm"}, "output_format must be one of"), + ("bytedance/seedance-2.0", {"camera_fixed": True}, "requires bytedance/seedance-1.5-pro"), + ("xai/grok-imagine-video", {"safety_identifier": "u1"}, "requires a Seedance model"), + ], +) +def test_per_model_guards_refuse_before_submit(account, account_captured, model, kwargs, message): + with pytest.raises(ValueError, match=message): + account.generate("x", model=model, **kwargs) + assert account_captured == {} + + +@pytest.mark.parametrize( + "model", ["bytedance/seedance-2.0", "bytedance/seedance-2.0-mini", "bytedance/seedance-2.5"] +) +def test_reference_clips_reach_the_body(account, account_captured, model): + # 2.5 takes clips since 2026-09-26 (gateway ceiling 30.2s), alongside the + # 2.0 family; images, audio and bitrate ride with them unchanged. + account.generate( + "follow the motion", + model=model, + reference_image_urls=["https://e/x.png"], + reference_videos=_CLIP, + reference_audios=[{"url": "https://example.com/a.mp3"}], + bitrate_mode="high", + ) + body = account_captured["body"] + assert body["reference_videos"] == _CLIP + assert body["reference_audios"] == [{"url": "https://example.com/a.mp3"}] + assert body["bitrate_mode"] == "high" + + +def test_bitrate_mode_models_unchanged(): + from blockrun_llm.validation import SEEDANCE_BITRATE_MODE_MODELS + + assert SEEDANCE_BITRATE_MODE_MODELS == { + "bytedance/seedance-2.0", + "bytedance/seedance-2.0-fast", + "bytedance/seedance-2.0-mini", + "bytedance/seedance-2.5", + } + + +def test_empty_reference_lists_are_omitted(account, account_captured): + account.generate( + "x", model="bytedance/seedance-2.0", reference_image_urls=[], reference_videos=[] + ) + assert "reference_image_urls" not in account_captured["body"] + assert "reference_videos" not in account_captured["body"] + + +def test_last_frame_response_is_not_dropped(): + result = VideoResponse( + created=1, + model="bytedance/seedance-2.0", + data=[ + { + "url": "https://example.com/movie.mp4", + "last_frame_url": "https://example.com/last.png", + "last_frame_backed_up": True, + } + ], + ) + assert result.data[0].last_frame_url == "https://example.com/last.png" + assert result.data[0].last_frame_backed_up is True