Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,19 @@ test that couldn't fail.

- **`n` is bounded 1–10 before payment.** Base's `image2image` schema is
`z.number().optional().default(1)` — no int, no bounds — so `n=1000` passed
validation, took payment, then 400'd at the provider and lost the prepaid
USDC. (Solana already bounds it. The gateway-side fix belongs in `blockrun`;
this closes it at the sidecar for both chains today.)
validation, earned a 402, got payment-verified, and only then 400'd at the
provider. (Solana already bounds it. The gateway-side fix belongs in
`blockrun`; this closes it at the sidecar for both chains today.)

**Correction (2026-07-16):** as first published, this entry said the round-trip
"lost the prepaid USDC". That is wrong, and the claim shipped in 0.7.2.
BlockRun's gateways settle **on success** — `settlePaymentWithRetry` runs after
the upstream call — so a request that dies at the provider settles nothing and
costs the caller nothing. The bound is still worth having: it saves a pointless
signed round-trip, a facilitator verify, and an upstream call, and returns a
clean local 400. It just was never about losing money. The error was inherited
from the SDK's `"API error after payment"` wording, which reads as *funds gone*
on failures that took nothing (fixed in blockrun-llm#25).

- **Post-settlement parse failures no longer answer 400 or skip the audit log.**
`pydantic.ValidationError` subclasses `ValueError`, and the SDK builds its
Expand Down
16 changes: 11 additions & 5 deletions blockrun_litellm/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,13 +825,19 @@ def _require_named_model(value: Any) -> Optional[str]:


def _require_image_n(value: Any) -> int:
"""Bound `n` locally, before it can cost anything.
"""Bound `n` locally so a doomed request never reaches the payment dance.

Base's `image2image` gateway schema is `z.number().optional().default(1)` —
no int, no bounds — so `n=1000` passes validation, takes payment, and only
then 400s at the provider, losing the prepaid USDC. Solana already bounds it
(`.int().min(1).max(10)`). 10 is the ceiling the Base *text-to-image* schema
uses, whose own comment names this exact loss.
no int, no bounds — so `n=1000` passes validation, earns a 402, gets
payment-verified, and only then 400s at the provider. Solana already bounds
it (`.int().min(1).max(10)`); 10 is the ceiling the Base *text-to-image*
schema uses.

No money is at stake, despite what the t2i schema's comment implies: the
gateways settle **on success**, so a request that dies at the provider
settles nothing. What this saves is a pointless signed round-trip — a
facilitator verify and an upstream call — and it answers 400 locally instead
of surfacing the provider's error.
"""
if isinstance(value, str) and not value.strip():
return 1 # blank multipart field means "unset"
Expand Down
7 changes: 5 additions & 2 deletions tests/test_proxy_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,9 +453,12 @@ def test_bad_typed_size_or_model_is_rejected_not_silently_defaulted(self, client
assert response.status_code == 400, f"{field}={bad!r} must not be guessed at"
mock.assert_not_called()

def test_n_is_bounded_before_it_can_cost_anything(self, client, monkeypatch):
def test_n_is_bounded_before_the_payment_dance(self, client, monkeypatch):
"""Base's image2image gateway schema has no int/bounds on n, so n=1000
passes zod, takes payment, then 400s at the provider — prepaid USDC gone.
passes zod, earns a 402, gets verified, then 400s at the provider.

No money is lost — the gateways settle on success — but the round-trip
is pure waste, and a local 400 beats the provider's error surfacing late.
"""
mock = _mock_adapter(monkeypatch, "image_generation_async", return_value=dict(OK_RESULT))
for bad in (0, -5, 1000, 11):
Expand Down