diff --git a/litellm/proxy/hooks/budget_reservation_reconcile_logger.py b/litellm/proxy/hooks/budget_reservation_reconcile_logger.py index 16ff6d2d2a3..6f9e2d644f5 100644 --- a/litellm/proxy/hooks/budget_reservation_reconcile_logger.py +++ b/litellm/proxy/hooks/budget_reservation_reconcile_logger.py @@ -63,7 +63,17 @@ class BudgetReservationReconcileLogger(CustomLogger): budget_reservation=budget_reservation, actual_cost=actual_cost, ) - except Exception: # noqa: BLE001 # a counter backend failure must still release the reservation + except Exception: # noqa: BLE001 # counter failure: refund only when no known spend + if actual_cost > 0: + # No Prisma ledger on this path. Releasing to 0.0 / finalizing would + # permanently refund completed work and block retry; leave the + # reservation open so settlement can retry and budgets fail closed. + verbose_proxy_logger.exception( + "Failed to reconcile budget reservation without a database logger; " + "retaining known spend %s for retry", + actual_cost, + ) + return verbose_proxy_logger.exception( "Failed to reconcile budget reservation without a database logger; releasing instead" ) diff --git a/tests/unit/proxy/hooks/test_budget_reservation_reconcile_logger.py b/tests/unit/proxy/hooks/test_budget_reservation_reconcile_logger.py index 006678a5739..182fa5c7bcf 100644 --- a/tests/unit/proxy/hooks/test_budget_reservation_reconcile_logger.py +++ b/tests/unit/proxy/hooks/test_budget_reservation_reconcile_logger.py @@ -151,6 +151,36 @@ async def test_success_falls_back_to_release_when_reconcile_fails( release.assert_awaited_once_with(budget_reservation=reservation) +@pytest.mark.asyncio +async def test_success_retains_known_spend_when_reconcile_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + reservation = _reservation() + monkeypatch.setattr( + "litellm.proxy.spend_tracking.budget_reservation.reconcile_budget_reservation", + AsyncMock(side_effect=RuntimeError("counter down")), + ) + release = AsyncMock() + monkeypatch.setattr( + "litellm.proxy.spend_tracking.budget_reservation.release_or_invalidate_budget_reservation", + release, + ) + logger = BudgetReservationReconcileLogger() + + await logger.async_log_success_event( + { + "litellm_params": {"metadata": {"user_api_key_budget_reservation": reservation}}, + "response_cost": 0.004, + }, + response_obj=object(), + start_time=datetime.now(UTC), + end_time=datetime.now(UTC), + ) + + release.assert_not_awaited() + assert reservation["finalized"] is False + + @pytest.fixture def spend_counter_cache(monkeypatch: pytest.MonkeyPatch) -> DualCache: cache: Final = DualCache()