diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a45251..fd29b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/blockrun_litellm/proxy.py b/blockrun_litellm/proxy.py index e404349..9cd1558 100644 --- a/blockrun_litellm/proxy.py +++ b/blockrun_litellm/proxy.py @@ -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" diff --git a/tests/test_proxy_media.py b/tests/test_proxy_media.py index b3dcf0b..b813610 100644 --- a/tests/test_proxy_media.py +++ b/tests/test_proxy_media.py @@ -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):