From f243bb6fe53b5d351f1572ac6fac9b4412b1c701 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Tue, 8 Sep 2026 13:00:26 -0500 Subject: [PATCH] fix(music): poll the job the gateway hands back, on both rails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Music is never fast: MiniMax takes one to three minutes and the gateway answers 202 + poll_url — since 2026-09-08, at once. MusicClient treated every non-200 as an error, so a music request could not succeed on either rail; the enterprise ledger showed 11 of 11 creates in 30 days answered 202, and this client raised "API error: 202" for each. The image client already polls. Its loop moves to jobs.py and both clients use it: the wallet rail replays the create's PAYMENT-SIGNATURE on each poll (the job is bound to that wallet, and settles on the completed poll); the account rail sees its 202 on the first post and polls with the key. A poll budget that runs out has cost nothing. --- blockrun_llm/image.py | 109 +++------------------ blockrun_llm/jobs.py | 123 +++++++++++++++++++++++ blockrun_llm/music.py | 32 ++++++ tests/unit/test_music_poll.py | 177 ++++++++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+), 95 deletions(-) create mode 100644 blockrun_llm/jobs.py create mode 100644 tests/unit/test_music_poll.py diff --git a/blockrun_llm/image.py b/blockrun_llm/image.py index 8c69058..c97e4ad 100644 --- a/blockrun_llm/image.py +++ b/blockrun_llm/image.py @@ -42,8 +42,8 @@ payment_mode, raise_for_api_key_402, resolve_api_key, - resolve_poll_url, ) +from .jobs import poll_until_completed from .tx_log import paid_request_error_prefix from .types import APIError, ImageResponse, PaymentError, retry_after_of from .validation import ( @@ -409,106 +409,25 @@ def _handle_payment_and_retry( retry_after=retry_after_of(retry_response), ) - def _absolute_url(self, url: str) -> str: - """Resolve a relative ``poll_url`` against the configured API host. - - Server-returned poll URLs look like ``/api/v1/images/generations/``; - our ``self.api_url`` already ends with ``/api`` so we strip it once - to avoid double-prefixing. - """ - if url.startswith(("http://", "https://")): - return url - return resolve_poll_url(url, self.api_url, self.api_key) - def _poll_until_completed( self, submit_resp: httpx.Response, payment_payload: str | None, ) -> ImageResponse: - """Poll the gateway's ``poll_url`` with the same PAYMENT-SIGNATURE - until the upstream returns the finished image. - - Settlement happens on the first ``status=completed`` poll, so - timeout = no spend. Returns the parsed :class:`ImageResponse`. - """ - import time as _time - - try: - submit_data = submit_resp.json() - except Exception: - submit_data = {} - - poll_url_rel = submit_data.get("poll_url") - 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) - # A signature exists only on the wallet rail; the key rides on the - # client's default headers. - poll_headers = {"PAYMENT-SIGNATURE": payment_payload} if payment_payload else {} - deadline = _time.monotonic() + self.IMAGE_POLL_BUDGET_SECONDS - last_status = submit_data.get("status", "queued") - - while _time.monotonic() < deadline: - _time.sleep(self.IMAGE_POLL_INTERVAL_SECONDS) - - poll_resp = self._client.get(poll_url, headers=poll_headers) - try: - poll_data = poll_resp.json() - except Exception: - poll_data = {} - last_status = poll_data.get("status", last_status) - - if poll_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(poll_resp, self.api_key) - # Settlement failed on this poll — surface the gateway reason. - raise build_payment_rejected_error(poll_resp) - - if last_status == "failed": - raise APIError( - f"Image generation failed upstream: {poll_data.get('error', 'unknown')}", - poll_resp.status_code, - sanitize_error_response(poll_data if isinstance(poll_data, dict) else {}), - retry_after=retry_after_of(poll_resp), - ) - - if poll_resp.status_code == 200 and last_status == "completed": - return ImageResponse(**poll_data) - - if poll_resp.status_code in (202, 504): - # 202 = still queued/in_progress; 504 = transient upstream - # hiccup. Both retriable inside the budget. - continue - - if poll_resp.status_code != 200: - try: - error_body = poll_resp.json() - except Exception: - error_body = {"error": "Request failed"} - raise APIError( - f"Image poll failed: HTTP {poll_resp.status_code}", - poll_resp.status_code, - sanitize_error_response(error_body), - retry_after=retry_after_of(poll_resp), - ) - - raise APIError( - ( - f"Image generation did not complete within " - f"{self.IMAGE_POLL_BUDGET_SECONDS:.0f}s " - f"(last status: {last_status}). Settlement only happens on " - "completion, so no payment was taken." - ), - 504, - {"id": job_id, "last_status": last_status}, + """Poll the gateway's ``poll_url`` until the upstream returns the + finished image. Settlement happens on the first ``status=completed`` + poll, so timeout = no spend. Shared with music — see jobs.py.""" + data = poll_until_completed( + self._client, + submit_resp, + payment_payload, + api_url=self.api_url, + api_key=self.api_key, + interval_seconds=self.IMAGE_POLL_INTERVAL_SECONDS, + budget_seconds=self.IMAGE_POLL_BUDGET_SECONDS, + label="Image", ) + return ImageResponse(**data) @property def payment_mode(self) -> str: diff --git a/blockrun_llm/jobs.py b/blockrun_llm/jobs.py new file mode 100644 index 0000000..dccc924 --- /dev/null +++ b/blockrun_llm/jobs.py @@ -0,0 +1,123 @@ +"""Polling for the gateway's async media jobs. + +A slow generation answers ``202`` with a ``poll_url`` and settles on the first +poll that observes ``completed``, so a poll that times out has cost nothing. +Images and music share this loop: the same statuses, the same settlement rule, +the same two rails. One copy, so the two clients cannot drift apart on how a +job ends. +""" + +from __future__ import annotations + +import time +from typing import Any + +import httpx + +from .apikey import raise_for_api_key_402, resolve_poll_url +from .types import APIError, retry_after_of +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. + + 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. + """ + if url.startswith(("http://", "https://")): + return url + return resolve_poll_url(url, api_url, api_key) + + +def poll_until_completed( + client: httpx.Client, + submit_resp: httpx.Response, + payment_payload: str | None, + *, + api_url: str, + api_key: str | None, + interval_seconds: float, + budget_seconds: float, + label: str, +) -> dict[str, Any]: + """Poll ``poll_url`` until the job completes; return the completed body. + + ``payment_payload`` is the create's PAYMENT-SIGNATURE on the wallet rail + (the job is bound to that wallet and settles against it) and ``None`` on + the account rail, where the key rides on the client's default headers. + ``label`` names the product in errors ("Image", "Music"). + """ + try: + submit_data = submit_resp.json() + except Exception: + submit_data = {} + + poll_url_rel = submit_data.get("poll_url") + 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 = absolute_poll_url(poll_url_rel, api_url, api_key) + poll_headers = {"PAYMENT-SIGNATURE": payment_payload} if payment_payload else {} + deadline = time.monotonic() + budget_seconds + last_status = submit_data.get("status", "queued") + + while time.monotonic() < deadline: + time.sleep(interval_seconds) + + poll_resp = client.get(poll_url, headers=poll_headers) + try: + poll_data = poll_resp.json() + except Exception: + poll_data = {} + last_status = poll_data.get("status", last_status) + + if poll_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(poll_resp, api_key) + # Settlement failed on this poll — surface the gateway reason. + raise build_payment_rejected_error(poll_resp) + + if last_status == "failed": + raise APIError( + f"{label} generation failed upstream: {poll_data.get('error', 'unknown')}", + poll_resp.status_code, + sanitize_error_response(poll_data if isinstance(poll_data, dict) else {}), + retry_after=retry_after_of(poll_resp), + ) + + if poll_resp.status_code == 200 and last_status == "completed": + tx_hash = poll_resp.headers.get("x-payment-receipt") + if tx_hash and "txHash" not in poll_data: + poll_data["txHash"] = tx_hash + return poll_data + + if poll_resp.status_code in (202, 504): + # 202 = still queued/in_progress; 504 = transient upstream + # hiccup. Both retriable inside the budget. + continue + + if poll_resp.status_code != 200: + try: + error_body = poll_resp.json() + except Exception: + error_body = {"error": "Request failed"} + raise APIError( + f"{label} poll failed: HTTP {poll_resp.status_code}", + poll_resp.status_code, + sanitize_error_response(error_body), + retry_after=retry_after_of(poll_resp), + ) + + 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." + ), + 504, + {"id": job_id, "last_status": last_status}, + ) diff --git a/blockrun_llm/music.py b/blockrun_llm/music.py index ca720c0..17c767f 100644 --- a/blockrun_llm/music.py +++ b/blockrun_llm/music.py @@ -46,6 +46,7 @@ raise_for_api_key_402, resolve_api_key, ) +from .jobs import poll_until_completed from .tx_log import paid_request_error_prefix from .types import APIError, MusicResponse, PaymentError, retry_after_of from .validation import ( @@ -71,6 +72,11 @@ class MusicClient: DEFAULT_API_URL = "https://blockrun.ai/api" DEFAULT_MODEL = "minimax/music-2.5+" DEFAULT_TIMEOUT = 210.0 # music gen takes 1-3 min + # A track takes one to three minutes and the gateway answers 202 + + # poll_url at once, so the wait happens here, poll by poll. Settlement is + # on the completed poll: a budget that runs out has cost nothing. + MUSIC_POLL_INTERVAL_SECONDS = 5.0 + MUSIC_POLL_BUDGET_SECONDS = 300.0 def __init__( self, @@ -194,6 +200,12 @@ def _request_with_payment(self, endpoint: str, body: dict[str, Any]) -> MusicRes raise_for_api_key_402(response, self.api_key) return self._handle_payment_and_retry(url, body, response) + # Account rail: the key already paid, so the job's 202 comes on the + # FIRST post. Music is never fast enough to finish inline, so without + # this branch every API-key music request raised "API error: 202". + if self.api_key and response.status_code == 202: + return self._poll_until_completed(response, None) + if response.status_code != 200: try: error_body = response.json() @@ -263,6 +275,11 @@ def _handle_payment_and_retry( raise_for_api_key_402(retry_response, self.api_key) raise PaymentError("Payment was rejected. Check your wallet balance.") + if retry_response.status_code == 202: + # The signed create is queued: replay the same signature on each + # poll — the job is bound to this wallet — and settle on completion. + return self._poll_until_completed(retry_response, payment_payload) + if retry_response.status_code != 200: try: error_body = retry_response.json() @@ -285,6 +302,21 @@ def _handle_payment_and_retry( return MusicResponse(**data) + def _poll_until_completed( + self, submit_resp: httpx.Response, payment_payload: str | None + ) -> MusicResponse: + data = poll_until_completed( + self._client, + submit_resp, + payment_payload, + api_url=self.api_url, + api_key=self.api_key, + interval_seconds=self.MUSIC_POLL_INTERVAL_SECONDS, + budget_seconds=self.MUSIC_POLL_BUDGET_SECONDS, + label="Music", + ) + return MusicResponse(**data) + @property def payment_mode(self) -> str: """Which rail this client pays on: ``"apikey"`` or ``"wallet"``. diff --git a/tests/unit/test_music_poll.py b/tests/unit/test_music_poll.py new file mode 100644 index 0000000..2b81d51 --- /dev/null +++ b/tests/unit/test_music_poll.py @@ -0,0 +1,177 @@ +"""Tests for the music-generation 202 + poll_url path. + +Music is never fast: MiniMax takes one to three minutes per track and the +gateway answers 202 + poll_url once its inline window is over — which, since +2026-09-08, is at once. This client treated every non-200 as an error, so on +both rails a music request could not succeed at all; the enterprise ledger +showed 11 of 11 creates in 30 days answered 202 and this SDK raised +"API error: 202" for each. The image client already polls; music mirrors it. + +``httpx.MockTransport`` keeps the network out. The poll interval is patched +to 0 so the loop spins instantly. +""" + +from __future__ import annotations + +import httpx +import pytest + +from blockrun_llm import MusicClient +from blockrun_llm.types import APIError + +from ..helpers import TEST_PRIVATE_KEY, build_payment_required_response + +KEY = "brk_live_testkey" + + +def _wallet_client(transport: httpx.MockTransport) -> MusicClient: + client = MusicClient(private_key=TEST_PRIVATE_KEY) + client._client = httpx.Client(transport=transport) + return client + + +def _apikey_client(transport: httpx.MockTransport, monkeypatch: pytest.MonkeyPatch) -> MusicClient: + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + monkeypatch.delenv("BLOCKRUN_WALLET_KEY", raising=False) + client = MusicClient() + client._client = httpx.Client(transport=transport, headers=client._client.headers) + return client + + +def _payment_required_402() -> httpx.Response: + return httpx.Response( + 402, + headers={"content-type": "application/json", "payment-required": build_payment_required_response()}, + json={"error": "Payment Required", "price": {"amount": "0.1575"}}, + ) + + +def _queued(job_id: str) -> httpx.Response: + return httpx.Response( + 202, + headers={"content-type": "application/json"}, + json={ + "id": job_id, "object": "audio.generation.job", "status": "queued", + "model": "minimax/music-2.5+", "poll_url": f"/api/v1/audio/generations/{job_id}", + "created": 1700000000, + }, + ) + + +def _completed(job_id: str) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "application/json", "x-payment-receipt": "0xabc"}, + json={ + "id": job_id, "object": "audio.generation.job", "status": "completed", + "model": "minimax/music-2.5+", "created": 1700000000, + "data": [{"url": "https://blockrun.ai/media/track.mp3", "duration_seconds": 182}], + "payment": {"status": "settled"}, + }, + ) + + +def test_music_wallet_rail_polls_to_completion(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(MusicClient, "MUSIC_POLL_INTERVAL_SECONDS", 0.0) + calls: list[httpx.Request] = [] + polls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + if request.method == "POST" and request.url.path.endswith("/v1/audio/generations"): + if "PAYMENT-SIGNATURE" not in request.headers: + return _payment_required_402() + return _queued("mus_1") + if request.method == "GET" and "/v1/audio/generations/mus_1" in request.url.path: + polls["n"] += 1 + if polls["n"] == 1: + return httpx.Response(202, headers={"content-type": "application/json"}, + json={"id": "mus_1", "status": "in_progress"}) + return _completed("mus_1") + return httpx.Response(404) + + result = _wallet_client(httpx.MockTransport(handler)).generate("chill lo-fi beats") + + assert [c.method for c in calls] == ["POST", "POST", "GET", "GET"] + # Every poll replays the signature the create was paid with; the job is + # bound to that wallet and settles on the completed poll. + assert calls[2].headers["PAYMENT-SIGNATURE"] == calls[1].headers["PAYMENT-SIGNATURE"] + assert calls[3].headers["PAYMENT-SIGNATURE"] == calls[1].headers["PAYMENT-SIGNATURE"] + assert result.data[0].url == "https://blockrun.ai/media/track.mp3" + assert result.data[0].duration_seconds == 182 + assert result.txHash == "0xabc" + + +def test_music_api_key_rail_polls_on_first_202(monkeypatch: pytest.MonkeyPatch) -> None: + # The account rail has already paid, so the 202 comes on the FIRST post + # and the polls carry the key, not a signature. + monkeypatch.setattr(MusicClient, "MUSIC_POLL_INTERVAL_SECONDS", 0.0) + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + if request.method == "POST": + return _queued("mus_2") + if request.method == "GET" and "/v1/audio/generations/mus_2" in request.url.path: + return _completed("mus_2") + return httpx.Response(404) + + result = _apikey_client(httpx.MockTransport(handler), monkeypatch).generate("epic orchestral") + + assert [c.method for c in calls] == ["POST", "GET"] + assert "PAYMENT-SIGNATURE" not in calls[1].headers + assert calls[1].headers.get("authorization") == f"Bearer {KEY}" + # The gateway's poll_url is /api/v1/...; api.blockrun.ai serves it at /v1/... + assert calls[1].url.path == "/v1/audio/generations/mus_2" + assert result.data[0].url == "https://blockrun.ai/media/track.mp3" + + +def test_music_poll_surfaces_upstream_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(MusicClient, "MUSIC_POLL_INTERVAL_SECONDS", 0.0) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + if "PAYMENT-SIGNATURE" not in request.headers: + return _payment_required_402() + return _queued("mus_3") + return httpx.Response(200, headers={"content-type": "application/json"}, json={ + "id": "mus_3", "status": "failed", "error": "The operation was aborted due to timeout", + "payment_status": "not_charged", + }) + + with pytest.raises(APIError) as excinfo: + _wallet_client(httpx.MockTransport(handler)).generate("waiting") + assert "aborted due to timeout" in str(excinfo.value) + + +def test_music_poll_times_out_without_settlement(monkeypatch: pytest.MonkeyPatch) -> None: + 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": + if "PAYMENT-SIGNATURE" not in request.headers: + return _payment_required_402() + return _queued("mus_4") + return httpx.Response(202, headers={"content-type": "application/json"}, + json={"id": "mus_4", "status": "in_progress"}) + + with pytest.raises(APIError) as excinfo: + _wallet_client(httpx.MockTransport(handler)).generate("forever") + assert excinfo.value.status_code == 504 + assert "no payment was taken" in str(excinfo.value).lower() + + +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: + if "PAYMENT-SIGNATURE" not in request.headers: + return _payment_required_402() + return httpx.Response(200, headers={"content-type": "application/json", "x-payment-receipt": "0xfast"}, json={ + "created": 1700000000, "model": "minimax/music-2.5+", + "data": [{"url": "https://blockrun.ai/media/fast.mp3"}], + }) + + result = _wallet_client(httpx.MockTransport(handler)).generate("quick jingle") + assert result.data[0].url == "https://blockrun.ai/media/fast.mp3" + assert result.txHash == "0xfast"