From 2235bf73b83c5292ba2798fe032dc9cbc4b936f0 Mon Sep 17 00:00:00 2001 From: Fsocietyhhh <1211904451@qq.com> Date: Wed, 23 Sep 2026 00:57:02 -0700 Subject: [PATCH 1/5] feat(video): expose Seedance reference media and output controls --- SEEDANCE_CAPABILITIES.md | 76 +++++++++++++++++++++++++++++++++ blockrun_llm/solana_client.py | 67 ++++++++++++++++++++++++++++- blockrun_llm/types.py | 2 + blockrun_llm/video.py | 57 ++++++++++++++++++++++--- tests/unit/test_solana_media.py | 25 +++++++++++ tests/unit/test_video_params.py | 61 ++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 SEEDANCE_CAPABILITIES.md diff --git a/SEEDANCE_CAPABILITIES.md b/SEEDANCE_CAPABILITIES.md new file mode 100644 index 0000000..7a470dc --- /dev/null +++ b/SEEDANCE_CAPABILITIES.md @@ -0,0 +1,76 @@ +# Seedance input and output capabilities + +The three gateways use the same public generation fields. Wallet authentication, +API-key holds, signed poll URLs and settlement timing are unchanged. + +## Supported combinations + +| Model | First + last frame | Reference images | Reference video/audio combinations | +| --- | --- | --- | --- | +| Seedance 1.5-pro | Yes | No | No | +| Seedance 2.0 / Fast / Mini | Yes | 1–9 | Image + video, image + audio, video + audio, or all three; 1–3 clips of each type | +| Seedance 2.5 | Yes | 1–30 | Still held pending render/cost verification | + +`image_url` means a first-frame seed. For a character/style image alongside a +reference video, use `reference_image_urls`, not `image_url`. Frame seeding and +reference mode remain mutually exclusive. Seedance 2.0 audio references require +at least one reference image or video. Upstream media duration, size and content +constraints still apply; accepting a URL does not verify the remote file. + +```json +{ + "model": "bytedance/seedance-2.0-fast", + "prompt": "Use image 1 for the character and video 1 for the motion", + "duration_seconds": 5, + "reference_image_urls": ["https://example.com/character.png"], + "reference_videos": [{"url": "https://example.com/motion.mp4"}], + "input_type": "reference", + "return_last_frame": true +} +``` + +POST to `/v1/videos/generations` or `/api/v1/videos/generations`. Native +`content[]` also works on these endpoints and `/v1/videos`: `reference_image`, +`reference_video`, `reference_audio`, `first_frame`, and `last_frame` roles map +to the corresponding validated flat fields. A role-less single image keeps its +existing first-frame meaning. Alternatively use `frame_images` with `frame_type` +or typed `input_references` with role `reference`. Use one media syntax per +request; conflicting aliases or media fields return 400 before payment. + +## Additional output controls + +| Field | Models | Values | +| --- | --- | --- | +| `bitrate_mode` | Seedance 2.x | `standard`, `high` | +| `output_format` | Seedance 2.5 | `mp4`, `mov` | +| `camera_fixed` | Seedance 1.5-pro | Boolean | +| `safety_identifier` | Seedance family | String | +| `return_last_frame` | Seedance family | Boolean | + +When the upstream returns a last frame, completed `data[0]` includes +`last_frame_url` and `last_frame_backed_up`. The frame uses the same storage +backup/fallback semantics as the video. Solana starts the copy without delaying +settlement, preserving its blockhash timing. An upstream that omits the frame +produces no invented frame URL. Failover is refused when it would drop a +requested control or an asset reference. + +Python uses the snake_case fields above. TypeScript uses `referenceImageUrls`, +`referenceVideos`, `referenceAudios`, `bitrateMode`, `outputFormat`, `cameraFixed`, +`safetyIdentifier`, `returnLastFrame`, and `inputType`. MCP exposes snake_case +fields and reserves the existing reference-media surcharge before payment. + +## Operational limits + +`R2V_ENABLED=false` still refuses NEW reference-video/audio jobs with 503. +This change does not modify deployment configuration or re-enable production. +Jobs already accepted remain pollable. Image-only references are not subject +to that operational switch. + +Automatic duration (`-1`), 2.5 editing/extension task modes, 2.5 reference media, +2.5 1080p, draft/flex service tiers, callbacks, and task-list/cancel APIs remain +outside this change. The first group needs verified cost/output bounds; the +lifecycle features need a separate ownership and settlement design. Known +unsupported request controls are rejected instead of silently discarded. + +New behavior is covered by local request-contract and mocked payment-lifecycle +tests. New paid upstream renders and production rollout are separate checks. diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index 32517f5..bf17237 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -2402,6 +2402,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, @@ -2435,6 +2441,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, @@ -2774,6 +2786,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 = 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, duration_seconds: int | None, aspect_ratio: str | None, @@ -2809,8 +2827,29 @@ def _build_video_body( "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.") + image_limit = 30 if (model or "").removeprefix("bytedance/") == "seedance-2.5" else 9 + if len(reference_image_urls) > image_limit: + raise ValueError(f"reference_image_urls accepts at most {image_limit} images.") + if (reference_videos or reference_audios) and ( + image_url or last_frame_url or real_face_asset_id + ): + raise ValueError( + "reference media is mutually exclusive with frame-seed inputs; use reference_image_urls." + ) + for clips in (reference_videos, reference_audios): + if clips is not None: + if not 1 <= len(clips) <= 3: + raise ValueError("reference media accepts 1 to 3 clips per type.") + if any( + not isinstance(clip, dict) + or not isinstance(clip.get("url"), str) + or not clip["url"].startswith(("https://", "http://")) + or clip.get("role", "reference") != "reference" + for clip in clips + ): + raise ValueError( + "reference clips require an http(s) URL and optional reference role." + ) 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_' " @@ -2828,6 +2867,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 is not None: + body["reference_videos"] = reference_videos + if reference_audios is not None: + 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: @@ -4612,6 +4663,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 +4689,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, 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/video.py b/blockrun_llm/video.py index 3ec36d5..9e22471 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -165,6 +165,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, @@ -194,11 +200,19 @@ def generate( `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. + reference_image_urls: Omni / multi-reference — up to 9 (2.0) or 30 (2.5) reference + image URLs for character/style consistency (Seedance 2.0/2.5). + Cite them as "image 1", "image 2" in the prompt. Mutually exclusive with `image_url`, `last_frame_url`, and `real_face_asset_id`. + reference_videos: Up to 3 http(s) motion references on Seedance 2.0. + May be combined with reference_image_urls and reference_audios. + reference_audios: Up to 3 http(s) audio references on Seedance 2.0. + Requires at least one reference image or video. + bitrate_mode: Seedance 2.x output bitrate, "standard" or "high". + output_format: Seedance 2.5 output container, "mp4" or "mov". + camera_fixed: Seedance 1.5-pro fixed-camera control. + safety_identifier: Safety identifier forwarded with a Seedance request. 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 @@ -261,8 +275,29 @@ def generate( "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.") + image_limit = 30 if (model or "").removeprefix("bytedance/") == "seedance-2.5" else 9 + if len(reference_image_urls) > image_limit: + raise ValueError(f"reference_image_urls accepts at most {image_limit} images.") + if (reference_videos or reference_audios) and ( + image_url or last_frame_url or real_face_asset_id + ): + raise ValueError( + "reference media is mutually exclusive with frame-seed inputs; use reference_image_urls." + ) + for clips in (reference_videos, reference_audios): + if clips is not None: + if not 1 <= len(clips) <= 3: + raise ValueError("reference media accepts 1 to 3 clips per type.") + if any( + not isinstance(clip, dict) + or not isinstance(clip.get("url"), str) + or not clip["url"].startswith(("https://", "http://")) + or clip.get("role", "reference") != "reference" + for clip in clips + ): + raise ValueError( + "reference clips require an http(s) URL and optional reference role." + ) 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_' " @@ -282,6 +317,18 @@ def generate( body["last_frame_url"] = last_frame_url if reference_image_urls: body["reference_image_urls"] = reference_image_urls + if reference_videos is not None: + body["reference_videos"] = reference_videos + if reference_audios is not None: + 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: diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index fadcb3b..71384d4 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -799,3 +799,28 @@ async def test_async_failed_job_is_booked(self) -> None: assert client._session_calls == 1 finally: await client._client.aclose() + + +@pytest.mark.asyncio +async def test_async_mixed_video_references(): + import json + + calls: list[httpx.Request] = [] + client = _make_async_client(_paid_flow(calls, _VIDEO_OK)) + await client.video( + "test", + 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, + ) + body = json.loads(calls[0].content) + 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["return_last_frame"] is True + await client.close() diff --git a/tests/unit/test_video_params.py b/tests/unit/test_video_params.py index 4cd3d6e..cfbb641 100644 --- a/tests/unit/test_video_params.py +++ b/tests/unit/test_video_params.py @@ -155,3 +155,64 @@ 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(client, captured): + client.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 = 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(client, captured): + images = ["https://example.com/person.png"] * 30 + client.generate( + "test", model="bytedance/seedance-2.5", reference_image_urls=images, output_format="mov" + ) + assert captured["body"]["reference_image_urls"] == images + assert captured["body"]["output_format"] == "mov" + with pytest.raises(ValueError, match="at most 30"): + client.generate( + "test", model="bytedance/seedance-2.5", reference_image_urls=images + images + ) + client.generate("test", model="bytedance/seedance-1.5-pro", camera_fixed=False) + assert captured["body"]["camera_fixed"] is False + + +def test_reference_media_cannot_be_frame_seeds(client): + with pytest.raises(ValueError, match="mutually exclusive"): + client.generate( + "test", + image_url="https://example.com/frame.png", + reference_videos=[{"url": "https://example.com/motion.mp4"}], + ) + + +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 From 9b85ae091f8069e30606d2eca8e01213c3610111 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 16:55:03 +0800 Subject: [PATCH 2/5] fix(video): guard Seedance per model, account-rail only refs, and poll account-rail jobs on Solana - One shared validate_video_request (validation.py) replaces the copies in VideoClient.generate and SolanaLLMClient._build_video_body. It mirrors the MCP's capability table. It refuses reference media on the wallet rails (both gateways 400 before quoting), clips on 2.5, reference images on models without them, audio-only references, and Seedance-only controls on the wrong model. It also refuses seedance-2.5 last frame on the Solana wallet gateway (MCP 0.53.1 parity), clip dicts with extra keys, and non-http(s) reference images. Empty reference lists are omitted consistently. - SolanaLLMClient / AsyncSolanaLLMClient media on the account rail: the first POST is the billed submit, and its 202 stub used to be returned as the result. VideoResponse then raised after the charge. The job is now polled unsigned to completion. 502/503 is not replayed (a replay could bill twice). A timeout says the account was billed. Co-Authored-By: Claude Opus 5.5 (1M context) --- SEEDANCE_CAPABILITIES.md | 76 ------ blockrun_llm/solana_client.py | 434 ++++++++++++++++++-------------- blockrun_llm/validation.py | 195 ++++++++++++++ blockrun_llm/video.py | 115 ++++----- tests/unit/test_solana_media.py | 168 +++++++++++-- tests/unit/test_video_params.py | 143 +++++++++-- 6 files changed, 747 insertions(+), 384 deletions(-) delete mode 100644 SEEDANCE_CAPABILITIES.md diff --git a/SEEDANCE_CAPABILITIES.md b/SEEDANCE_CAPABILITIES.md deleted file mode 100644 index 7a470dc..0000000 --- a/SEEDANCE_CAPABILITIES.md +++ /dev/null @@ -1,76 +0,0 @@ -# Seedance input and output capabilities - -The three gateways use the same public generation fields. Wallet authentication, -API-key holds, signed poll URLs and settlement timing are unchanged. - -## Supported combinations - -| Model | First + last frame | Reference images | Reference video/audio combinations | -| --- | --- | --- | --- | -| Seedance 1.5-pro | Yes | No | No | -| Seedance 2.0 / Fast / Mini | Yes | 1–9 | Image + video, image + audio, video + audio, or all three; 1–3 clips of each type | -| Seedance 2.5 | Yes | 1–30 | Still held pending render/cost verification | - -`image_url` means a first-frame seed. For a character/style image alongside a -reference video, use `reference_image_urls`, not `image_url`. Frame seeding and -reference mode remain mutually exclusive. Seedance 2.0 audio references require -at least one reference image or video. Upstream media duration, size and content -constraints still apply; accepting a URL does not verify the remote file. - -```json -{ - "model": "bytedance/seedance-2.0-fast", - "prompt": "Use image 1 for the character and video 1 for the motion", - "duration_seconds": 5, - "reference_image_urls": ["https://example.com/character.png"], - "reference_videos": [{"url": "https://example.com/motion.mp4"}], - "input_type": "reference", - "return_last_frame": true -} -``` - -POST to `/v1/videos/generations` or `/api/v1/videos/generations`. Native -`content[]` also works on these endpoints and `/v1/videos`: `reference_image`, -`reference_video`, `reference_audio`, `first_frame`, and `last_frame` roles map -to the corresponding validated flat fields. A role-less single image keeps its -existing first-frame meaning. Alternatively use `frame_images` with `frame_type` -or typed `input_references` with role `reference`. Use one media syntax per -request; conflicting aliases or media fields return 400 before payment. - -## Additional output controls - -| Field | Models | Values | -| --- | --- | --- | -| `bitrate_mode` | Seedance 2.x | `standard`, `high` | -| `output_format` | Seedance 2.5 | `mp4`, `mov` | -| `camera_fixed` | Seedance 1.5-pro | Boolean | -| `safety_identifier` | Seedance family | String | -| `return_last_frame` | Seedance family | Boolean | - -When the upstream returns a last frame, completed `data[0]` includes -`last_frame_url` and `last_frame_backed_up`. The frame uses the same storage -backup/fallback semantics as the video. Solana starts the copy without delaying -settlement, preserving its blockhash timing. An upstream that omits the frame -produces no invented frame URL. Failover is refused when it would drop a -requested control or an asset reference. - -Python uses the snake_case fields above. TypeScript uses `referenceImageUrls`, -`referenceVideos`, `referenceAudios`, `bitrateMode`, `outputFormat`, `cameraFixed`, -`safetyIdentifier`, `returnLastFrame`, and `inputType`. MCP exposes snake_case -fields and reserves the existing reference-media surcharge before payment. - -## Operational limits - -`R2V_ENABLED=false` still refuses NEW reference-video/audio jobs with 503. -This change does not modify deployment configuration or re-enable production. -Jobs already accepted remain pollable. Image-only references are not subject -to that operational switch. - -Automatic duration (`-1`), 2.5 editing/extension task modes, 2.5 reference media, -2.5 1080p, draft/flex service tiers, callbacks, and task-list/cancel APIs remain -outside this change. The first group needs verified cost/output bounds; the -lifecycle features need a separate ownership and settlement design. Known -unsupported request controls are rejected instead of silently discarded. - -New behavior is covered by local request-contract and mocked payment-lifecycle -tests. New paid upstream renders and production rollout are separate checks. diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index bf17237..f6caffe 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -101,6 +101,7 @@ validate_image_quality, validate_max_tokens, validate_video_input_type, + validate_video_request, ) try: @@ -2021,7 +2022,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,7 +2034,14 @@ 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() @@ -2045,61 +2056,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"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), + ) # Step 4: slow path — poll until completed (or budget exhausted). try: @@ -2116,7 +2139,10 @@ def _request_image_with_payment( {"response": submit_data}, ) poll_url = self._absolute_url(poll_url_rel) - if settled_at_submit: + # The account rail bills at accept, so for messages it behaves like a + # route that settles at submit, whatever the wallet rail would do. + 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 +2150,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 +2251,7 @@ 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 ""), + + (" (payment was settled at submit)" if charged_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 +2267,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 +2297,26 @@ 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}). The account was billed when the " + "job was accepted; it 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, @@ -2428,6 +2462,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 is billed when accepted, not on completion. + Args: input_type: Optional assertion of the seed mode — ``text`` / ``image`` / ``first_last_frame`` / ``reference``. The gateway @@ -2456,6 +2498,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( @@ -2786,12 +2829,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 = 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, + 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, @@ -2801,64 +2844,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." - ) - image_limit = 30 if (model or "").removeprefix("bytedance/") == "seedance-2.5" else 9 - if len(reference_image_urls) > image_limit: - raise ValueError(f"reference_image_urls accepts at most {image_limit} images.") - if (reference_videos or reference_audios) and ( - image_url or last_frame_url or real_face_asset_id - ): - raise ValueError( - "reference media is mutually exclusive with frame-seed inputs; use reference_image_urls." - ) - for clips in (reference_videos, reference_audios): - if clips is not None: - if not 1 <= len(clips) <= 3: - raise ValueError("reference media accepts 1 to 3 clips per type.") - if any( - not isinstance(clip, dict) - or not isinstance(clip.get("url"), str) - or not clip["url"].startswith(("https://", "http://")) - or clip.get("role", "reference") != "reference" - for clip in clips - ): - raise ValueError( - "reference clips require an http(s) URL and optional reference role." - ) - 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: @@ -2867,9 +2879,9 @@ 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 is not None: + if reference_videos: body["reference_videos"] = reference_videos - if reference_audios is not None: + if reference_audios: body["reference_audios"] = reference_audios if bitrate_mode is not None: body["bitrate_mode"] = bitrate_mode @@ -4704,6 +4716,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( @@ -5136,7 +5149,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 @@ -5147,7 +5163,14 @@ 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() @@ -5161,63 +5184,73 @@ async def _request_image_with_payment( ) 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"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), + ) # Step 4: slow path — poll until completed (or budget exhausted). try: @@ -5230,7 +5263,10 @@ async def _request_image_with_payment( if not poll_url_rel: raise APIError("Slow-path 202 missing poll_url", 202, {"response": submit_data}) poll_url = self._absolute_url(poll_url_rel) - if settled_at_submit: + # The account rail bills at accept, so for messages it behaves like a + # route that settles at submit, whatever the wallet rail would do. + 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 @@ -5238,10 +5274,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 @@ -5332,7 +5367,7 @@ 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 ""), + + (" (payment was settled at submit)" if charged_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), @@ -5346,7 +5381,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 @@ -5374,17 +5409,26 @@ 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}). The account was billed when the " + "job was accepted; it 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/validation.py b/blockrun_llm/validation.py index 0ebf5a5..d462738 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -195,6 +195,201 @@ 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. 2.5 is absent on purpose: it takes reference +# images (supportsReferenceImages) but not clips (supportsReferenceMedia:false). +SEEDANCE_REFERENCE_MEDIA_MODELS = frozenset( + {"bytedance/seedance-2.0", "bytedance/seedance-2.0-fast", "bytedance/seedance-2.0-mini"} +) +SEEDANCE_BITRATE_MODE_MODELS = SEEDANCE_REFERENCE_MEDIA_MODELS | {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))}." + + ( + " 2.5 takes reference IMAGES (up to 30) but no clips." + if model == SEEDANCE_25 + else "" + ) + ) + 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 9e22471..1996c93 100644 --- a/blockrun_llm/video.py +++ b/blockrun_llm/video.py @@ -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 @@ -198,21 +199,30 @@ 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 (2.0) or 30 (2.5) reference - image URLs for character/style consistency (Seedance 2.0/2.5). - Cite them as "image 1", "image 2" in the prompt. - Mutually exclusive with `image_url`, `last_frame_url`, and - `real_face_asset_id`. - reference_videos: Up to 3 http(s) motion references on Seedance 2.0. - May be combined with reference_image_urls and reference_audios. - reference_audios: Up to 3 http(s) audio references on Seedance 2.0. - Requires at least one reference image or video. - bitrate_mode: Seedance 2.x output bitrate, "standard" or "high". - output_format: Seedance 2.5 output container, "mp4" or "mov". - camera_fixed: Seedance 1.5-pro fixed-camera control. - safety_identifier: Safety identifier forwarded with a Seedance request. + 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 — not 2.5. Account rail + only. **Cost:** every reference clip is billed at the model's + 15.2s reference ceiling whatever its real length, 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 @@ -249,66 +259,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." - ) - image_limit = 30 if (model or "").removeprefix("bytedance/") == "seedance-2.5" else 9 - if len(reference_image_urls) > image_limit: - raise ValueError(f"reference_image_urls accepts at most {image_limit} images.") - if (reference_videos or reference_audios) and ( - image_url or last_frame_url or real_face_asset_id - ): - raise ValueError( - "reference media is mutually exclusive with frame-seed inputs; use reference_image_urls." - ) - for clips in (reference_videos, reference_audios): - if clips is not None: - if not 1 <= len(clips) <= 3: - raise ValueError("reference media accepts 1 to 3 clips per type.") - if any( - not isinstance(clip, dict) - or not isinstance(clip.get("url"), str) - or not clip["url"].startswith(("https://", "http://")) - or clip.get("role", "reference") != "reference" - for clip in clips - ): - raise ValueError( - "reference clips require an http(s) URL and optional reference role." - ) - 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: @@ -317,9 +294,9 @@ def generate( body["last_frame_url"] = last_frame_url if reference_image_urls: body["reference_image_urls"] = reference_image_urls - if reference_videos is not None: + if reference_videos: body["reference_videos"] = reference_videos - if reference_audios is not None: + if reference_audios: body["reference_audios"] = reference_audios if bitrate_mode is not None: body["bitrate_mode"] = bitrate_mode diff --git a/tests/unit/test_solana_media.py b/tests/unit/test_solana_media.py index 71384d4..c33af0e 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" @@ -801,26 +808,143 @@ async def test_async_failed_job_is_booked(self) -> None: await client._client.aclose() -@pytest.mark.asyncio -async def test_async_mixed_video_references(): - import json - - calls: list[httpx.Request] = [] - client = _make_async_client(_paid_flow(calls, _VIDEO_OK)) - await client.video( - "test", - 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, +# --------------------------------------------------------------------------- +# 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}"} ) - body = json.loads(calls[0].content) - 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["return_last_frame"] is True - await client.close() + 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_the_account_was_billed(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SolanaLLMClient, "VIDEO_POLL_BUDGET_SECONDS", 0.0) + client = _account_client(_account_handler([], [])) + with pytest.raises(APIError, match="billed when the job was accepted") as exc: + client.video("x", model="bytedance/seedance-2.0") + assert exc.value.response["id"] == "V1" + + 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 == [] diff --git a/tests/unit/test_video_params.py b/tests/unit/test_video_params.py index cfbb641..24daa9c 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)], ) @@ -157,8 +186,8 @@ def test_input_type_mismatch_is_left_to_the_gateway(client, captured): assert captured["body"]["input_type"] == "image" -def test_mixed_references_and_controls_reach_body(client, captured): - client.generate( +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"], @@ -169,7 +198,7 @@ def test_mixed_references_and_controls_reach_body(client, captured): return_last_frame=True, input_type="reference", ) - body = captured["body"] + 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"] @@ -178,30 +207,100 @@ def test_mixed_references_and_controls_reach_body(client, captured): assert body["input_type"] == "reference" -def test_25_reference_limit_and_output_controls(client, captured): +def test_25_reference_limit_and_output_controls(account, account_captured): images = ["https://example.com/person.png"] * 30 - client.generate( + account.generate( "test", model="bytedance/seedance-2.5", reference_image_urls=images, output_format="mov" ) - assert captured["body"]["reference_image_urls"] == images - assert captured["body"]["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"): - client.generate( + account.generate( "test", model="bytedance/seedance-2.5", reference_image_urls=images + images ) - client.generate("test", model="bytedance/seedance-1.5-pro", camera_fixed=False) - assert captured["body"]["camera_fixed"] is False + 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(client): +def test_reference_media_cannot_be_frame_seeds(account): with pytest.raises(ValueError, match="mutually exclusive"): - client.generate( + 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", + [ + # 2.5 takes reference images but not clips + ("bytedance/seedance-2.5", {"reference_videos": _CLIP}, "2.5 takes reference IMAGES"), + ( + "bytedance/seedance-2.5", + {"reference_image_urls": ["https://e/x.png"], "reference_audios": _CLIP}, + "does not accept reference video or audio", + ), + ( + "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 == {} + + +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, From 23c421479133d0df8945e8d537556fff02cb1d90 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 16:55:10 +0800 Subject: [PATCH 3/5] docs(video): Seedance capabilities per model and rail, reference-clip billing, changelog Replaces the root SEEDANCE_CAPABILITIES.md, which described gateway and MCP internals and claimed parity this SDK does not have (2.5 last frame on Solana, reference media on every rail). The new doc lives in docs/ and covers only what the SDK accepts. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 42 +++++++++++++++++++++++++ docs/seedance-capabilities.md | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 docs/seedance-capabilities.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eacdf2..eeaeeeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ 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). + - 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 15.2s reference ceiling each, 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, clips on 2.5, reference images 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 the account was billed, and the error carries the + `poll_url` so the job can still be fetched. + - Both the sync and async clients are fixed. + ## 1.17.1 — 2026-09-30 ### Fixed diff --git a/docs/seedance-capabilities.md b/docs/seedance-capabilities.md new file mode 100644 index 0000000..e829e3c --- /dev/null +++ b/docs/seedance-capabilities.md @@ -0,0 +1,58 @@ +# 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 | No | +| 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 the job is billed when it is accepted, not on completion. +A job that times out in the SDK has already been paid for. It stays claimable +for about 48h via the `poll_url` in the error. + +## Cost of reference clips + +Reference video and audio are billed per reference second, at the model's +15.2s reference ceiling, whatever the clip's real length. 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. From e66034f8c921bd7097a5400325081d33225205c8 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 22:22:00 +0800 Subject: [PATCH 4/5] fix(video): pin poll_url origin, account-rail music/speech polling, 2.5 reference clips - VideoClient and the shared jobs.py poller returned an absolute poll_url before resolve_poll_url could pin it, so a response naming another host received the API key. Every poll URL now goes through the pin; a refusal raises PollOriginRefusedError (APIError + ValueError) with id and poll_url. Solana account-rail polling uses the same helper. - Solana music/speech/sound_effect with an API key route through the media helper: no 5xx replay of the billed submit, a 202 job is polled unsigned to completion. Wallet rail unchanged. Sync and async. - seedance-2.5 accepts reference_videos/reference_audios (gateway supportsReferenceMedia, 30.2s per-clip ceiling); bitrate_mode models unchanged. Docs state the ceiling per model. --- CHANGELOG.md | 21 ++- blockrun_llm/jobs.py | 37 ++++- blockrun_llm/solana_client.py | 100 +++++++++++--- blockrun_llm/validation.py | 29 ++-- blockrun_llm/video.py | 17 +-- docs/seedance-capabilities.md | 7 +- tests/unit/test_poll_origin_pin.py | 118 ++++++++++++++++ tests/unit/test_solana_media.py | 213 +++++++++++++++++++++++++++++ tests/unit/test_video_params.py | 54 +++++++- 9 files changed, 537 insertions(+), 59 deletions(-) create mode 100644 tests/unit/test_poll_origin_pin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eeaeeeb..ef34734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,22 @@ All notable changes to blockrun-llm will be documented in this file. ### Added - **Seedance reference media and output controls** on `VideoClient.generate`, `SolanaLLMClient.video` and `AsyncSolanaLLMClient.video`: - - `reference_videos` and `reference_audios` (up to 3 each). + - `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 15.2s reference ceiling each, so they can - multiply a render's price. See `docs/seedance-capabilities.md`. Contributed + 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, clips on 2.5, reference images on models - that do not take them, audio without an image or video, `output_format` off + 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. @@ -43,6 +44,16 @@ All notable changes to blockrun-llm will be documented in this file. - A timeout now says the account was billed, 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 diff --git a/blockrun_llm/jobs.py b/blockrun_llm/jobs.py index dccc924..c63ffb5 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") diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index f6caffe..b70d20a 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 ( @@ -579,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" @@ -1934,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 @@ -1945,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 @@ -1981,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. @@ -2048,7 +2056,7 @@ def _request_image_with_payment( 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), @@ -2118,7 +2126,7 @@ def _request_image_with_payment( 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}", + 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), @@ -2138,7 +2146,7 @@ def _request_image_with_payment( 202, {"response": submit_data}, ) - poll_url = self._absolute_url(poll_url_rel) + poll_url = self._absolute_url(poll_url_rel, job_id) # The account rail bills at accept, so for messages it behaves like a # route that settles at submit, whatever the wallet rail would do. charged_at_submit = settled_at_submit or account_job @@ -2551,6 +2559,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, @@ -2574,7 +2605,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) @@ -2608,7 +2641,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) @@ -2634,7 +2667,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) @@ -4638,15 +4673,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 @@ -4764,6 +4800,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, @@ -4783,7 +4837,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) @@ -4808,7 +4864,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) @@ -4833,8 +4889,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) @@ -5177,7 +5233,7 @@ async def _request_image_with_payment( 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), @@ -5246,7 +5302,7 @@ async def _request_image_with_payment( 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}", + 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), @@ -5262,7 +5318,7 @@ 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) + poll_url = self._absolute_url(poll_url_rel, job_id) # The account rail bills at accept, so for messages it behaves like a # route that settles at submit, whatever the wallet rail would do. charged_at_submit = settled_at_submit or account_job diff --git a/blockrun_llm/validation.py b/blockrun_llm/validation.py index d462738..6f1bece 100644 --- a/blockrun_llm/validation.py +++ b/blockrun_llm/validation.py @@ -207,12 +207,28 @@ def validate_video_input_type(input_type: str | None) -> None: "bytedance/seedance-2.0-mini": 9, SEEDANCE_25: 30, } -# Reference VIDEO/AUDIO clips. 2.5 is absent on purpose: it takes reference -# images (supportsReferenceImages) but not clips (supportsReferenceMedia:false). +# 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"} + { + "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_BITRATE_MODE_MODELS = SEEDANCE_REFERENCE_MEDIA_MODELS | {SEEDANCE_25} SEEDANCE_MAX_REFERENCE_CLIPS = 3 SEEDANCE_BITRATE_MODES = ("standard", "high") SEEDANCE_OUTPUT_FORMATS = ("mp4", "mov") @@ -322,11 +338,6 @@ def validate_video_request( raise ValueError( f"Model {model} does not accept reference video or audio clips. " f"Supported: {', '.join(sorted(SEEDANCE_REFERENCE_MEDIA_MODELS))}." - + ( - " 2.5 takes reference IMAGES (up to 30) but no clips." - if model == SEEDANCE_25 - else "" - ) ) for field, clips in ( ("reference_videos", reference_videos), diff --git a/blockrun_llm/video.py b/blockrun_llm/video.py index 1996c93..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, @@ -210,10 +210,11 @@ def generate( `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 — not 2.5. Account rail + 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 - 15.2s reference ceiling whatever its real length, so one clip - can multiply the price of a short render several times. + 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. @@ -440,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 @@ -550,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 index e829e3c..d4dfbff 100644 --- a/docs/seedance-capabilities.md +++ b/docs/seedance-capabilities.md @@ -14,7 +14,7 @@ table in `blockrun-mcp/src/tools/video.ts`, and both should change together. | --- | --- | --- | --- | | 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 | No | +| 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. @@ -38,8 +38,9 @@ for about 48h via the `poll_url` in the error. ## Cost of reference clips Reference video and audio are billed per reference second, at the model's -15.2s reference ceiling, whatever the clip's real length. The gateway only -sees URLs, never durations. One clip on a 5s 720p seedance-2.0-mini render is +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. 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 c33af0e..c8c9d95 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -948,3 +948,216 @@ def test_seedance_25_last_frame_refused_on_solana_wallet(self) -> None: 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 24daa9c..e6c48a2 100644 --- a/tests/unit/test_video_params.py +++ b/tests/unit/test_video_params.py @@ -241,13 +241,30 @@ def test_reference_media_cannot_be_frame_seeds(account): @pytest.mark.parametrize( "model, kwargs, message", [ - # 2.5 takes reference images but not clips - ("bytedance/seedance-2.5", {"reference_videos": _CLIP}, "2.5 takes reference IMAGES"), + # 1.5-pro takes no reference clips at all ( - "bytedance/seedance-2.5", + "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"]}, @@ -293,6 +310,37 @@ def test_per_model_guards_refuse_before_submit(account, account_captured, model, 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=[] From ff1baa40db2eb0bdb37a567376a1a02ac3d2cb7e Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 2 Oct 2026 22:28:31 +0800 Subject: [PATCH 5/5] fix(video): account rail reserves credit at accept and charges on completion; say so in errors and docs --- CHANGELOG.md | 5 +++-- blockrun_llm/jobs.py | 9 ++++++-- blockrun_llm/solana_client.py | 38 ++++++++++++++++++++++----------- docs/seedance-capabilities.md | 7 +++--- tests/unit/test_music_poll.py | 23 ++++++++++++++++++++ tests/unit/test_solana_media.py | 21 ++++++++++++++++-- 6 files changed, 81 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef34734..fd0e320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,9 @@ All notable changes to blockrun-llm will be documented in this file. unsigned. - A 502/503 on that POST is no longer replayed, because a replay could bill the job twice. - - A timeout now says the account was billed, and the error carries the - `poll_url` so the job can still be fetched. + - 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 diff --git a/blockrun_llm/jobs.py b/blockrun_llm/jobs.py index c63ffb5..dc1b3b5 100644 --- a/blockrun_llm/jobs.py +++ b/blockrun_llm/jobs.py @@ -140,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 b70d20a..d43060d 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -2147,8 +2147,9 @@ def _request_image_with_payment( {"response": submit_data}, ) poll_url = self._absolute_url(poll_url_rel, job_id) - # The account rail bills at accept, so for messages it behaves like a - # route that settles at submit, whatever the wallet rail would do. + # 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 @@ -2259,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 charged_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), @@ -2305,9 +2310,10 @@ def _request_image_with_payment( ( ( f"{label} did not complete within {budget:.0f}s " - f"(last status: {last_status}). The account was billed when the " - "job was accepted; it stays claimable for ~48h — re-poll " - "poll_url with the same API key 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 account_job else ( @@ -2476,7 +2482,7 @@ def video( 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 is billed when accepted, not on completion. + job's credit is reserved when accepted and charged when it completes. Args: input_type: Optional assertion of the seed mode — ``text`` / @@ -5319,8 +5325,9 @@ async def _request_image_with_payment( if not poll_url_rel: raise APIError("Slow-path 202 missing poll_url", 202, {"response": submit_data}) poll_url = self._absolute_url(poll_url_rel, job_id) - # The account rail bills at accept, so for messages it behaves like a - # route that settles at submit, whatever the wallet rail would do. + # 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 @@ -5423,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 charged_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), @@ -5465,9 +5476,10 @@ async def _request_image_with_payment( ( ( f"{label} did not complete within {budget:.0f}s " - f"(last status: {last_status}). The account was billed when the " - "job was accepted; it stays claimable for ~48h — re-poll " - "poll_url with the same API key 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 account_job else ( diff --git a/docs/seedance-capabilities.md b/docs/seedance-capabilities.md index d4dfbff..30cba8c 100644 --- a/docs/seedance-capabilities.md +++ b/docs/seedance-capabilities.md @@ -31,9 +31,10 @@ table in `blockrun-mcp/src/tools/video.ts`, and both should change together. | 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 the job is billed when it is accepted, not on completion. -A job that times out in the SDK has already been paid for. It stays claimable -for about 48h via the `poll_url` in the error. +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 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_solana_media.py b/tests/unit/test_solana_media.py index c8c9d95..c6d48be 100644 --- a/tests/unit/test_solana_media.py +++ b/tests/unit/test_solana_media.py @@ -899,13 +899,30 @@ def test_billed_submit_is_never_replayed_on_5xx(self, status: int) -> None: client.video("x", model="bytedance/seedance-2.0") assert len(posts) == 1 - def test_timeout_says_the_account_was_billed(self, monkeypatch: pytest.MonkeyPatch) -> None: + 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="billed when the job was accepted") as exc: + 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] = []