fix(proxy): defer background reservation settlement until usage arrives

This commit is contained in:
er-s-an 2026-10-11 16:28:45 +08:00
parent 6338277224
commit 5bdb4e6fcd
3 changed files with 170 additions and 17 deletions

View file

@ -8,11 +8,15 @@ from datetime import datetime
from typing import Final
from litellm._logging import verbose_proxy_logger
from litellm.constants import BACKGROUND_INTERACTION_COST_POLLING_ENABLED
from litellm.integrations.custom_logger import CustomLogger
from litellm.interactions.background_cost_polling import is_pollable_background_interaction
from litellm.litellm_core_utils.core_helpers import (
budget_reservation_from_metadata,
get_litellm_metadata_from_kwargs,
get_litellm_metadata_from_kwargs, # pyright: ignore[reportUnknownVariableType] # shared helper has a legacy untyped signature
)
from litellm.types.interactions import InteractionsAPIResponse
class BudgetReservationReconcileLogger(CustomLogger):
"""Settle auth's spend-counter reservation on success when ProxyDBLogger is absent.
@ -27,35 +31,44 @@ class BudgetReservationReconcileLogger(CustomLogger):
async def async_log_success_event(
self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
del response_obj, start_time, end_time
metadata: Final = get_litellm_metadata_from_kwargs(kwargs=dict(kwargs))
metadata: Final = get_litellm_metadata_from_kwargs(kwargs=dict(kwargs)) # pyright: ignore[reportUnknownVariableType] # shared helper returns untyped metadata
if not isinstance(metadata, Mapping):
return
budget_reservation: Final = budget_reservation_from_metadata(metadata)
budget_reservation: Final = budget_reservation_from_metadata(metadata) # pyright: ignore[reportUnknownArgumentType] # helper only reads known keys from the checked mapping
if not isinstance(budget_reservation, dict) or budget_reservation.get("finalized") is True:
return
sl_object = kwargs.get("standard_logging_object")
response_cost: object
if isinstance(sl_object, Mapping):
response_cost = sl_object.get("response_cost")
else:
response_cost = kwargs.get("response_cost")
actual_cost: Final = float(response_cost or 0.0)
sl_object: Final = kwargs.get("standard_logging_object")
response_cost: Final[object] = ( # pyright: ignore[reportUnknownVariableType] # callback payloads are legacy untyped mappings
sl_object.get("response_cost") # pyright: ignore[reportUnknownMemberType] # float below validates the untyped cost value
if isinstance(sl_object, Mapping)
else kwargs.get("response_cost")
)
if (
response_cost is None
and BACKGROUND_INTERACTION_COST_POLLING_ENABLED
and isinstance(response_obj, InteractionsAPIResponse)
and response_obj.usage is None
and is_pollable_background_interaction(response_obj)
):
return
actual_cost: Final = float(response_cost or 0.0) # pyright: ignore[reportArgumentType] # float validates legacy callback values at runtime
from litellm.proxy.spend_tracking.budget_reservation import reconcile_budget_reservation
from litellm.proxy.spend_tracking.budget_reservation import (
reconcile_budget_reservation, # pyright: ignore[reportUnknownVariableType] # shared reconciler accepts legacy reservation dictionaries
)
try:
await reconcile_budget_reservation(
budget_reservation=budget_reservation,
actual_cost=actual_cost,
)
except Exception:
except Exception: # noqa: BLE001 # a counter backend failure must still release the reservation
verbose_proxy_logger.exception(
"Failed to reconcile budget reservation without a database logger; releasing instead"
)
from litellm.proxy.spend_tracking.budget_reservation import (
release_or_invalidate_budget_reservation,
release_or_invalidate_budget_reservation, # pyright: ignore[reportUnknownVariableType] # shared release accepts legacy reservation dictionaries
)
await release_or_invalidate_budget_reservation(budget_reservation=budget_reservation)

View file

@ -2910,9 +2910,7 @@ def cost_tracking():
BudgetReservationReconcileLogger,
)
litellm.logging_callback_manager.add_litellm_async_success_callback(
BudgetReservationReconcileLogger()
)
litellm.logging_callback_manager.add_litellm_async_success_callback(BudgetReservationReconcileLogger())
async def _drain_spend_event_producer_on_shutdown() -> None:

View file

@ -1,13 +1,18 @@
"""Unit tests for BudgetReservationReconcileLogger (#45895)."""
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Final
from unittest.mock import AsyncMock
import pytest
from litellm.caching import DualCache
from litellm.proxy import proxy_server
from litellm.proxy.hooks.budget_reservation_reconcile_logger import (
BudgetReservationReconcileLogger,
)
from litellm.types.interactions import InteractionsAPIResponse
def _reservation(callback_bound: bool = True) -> dict:
@ -144,3 +149,140 @@ async def test_success_falls_back_to_release_when_reconcile_fails(
)
release.assert_awaited_once_with(budget_reservation=reservation)
@pytest.fixture
def spend_counter_cache(monkeypatch: pytest.MonkeyPatch) -> DualCache:
cache: Final = DualCache()
monkeypatch.setattr(proxy_server, "spend_counter_cache", cache)
monkeypatch.setattr(proxy_server, "prisma_client", None)
return cache
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ("in_progress", "queued"))
@pytest.mark.parametrize("standard_logging", (False, True))
async def test_background_interaction_keeps_reservation_until_final_cost(
spend_counter_cache: DualCache, status: str, standard_logging: bool
) -> None:
reservation: Final = _reservation()
counter_key: Final = "spend:key:hashed"
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=reservation["reserved_cost"])
logger: Final = BudgetReservationReconcileLogger()
now: Final = datetime(2026, 1, 1, tzinfo=UTC)
kwargs: Final = {
"litellm_params": {"metadata": {"user_api_key_budget_reservation": reservation}},
**({"standard_logging_object": {"response_cost": None}, "response_cost": 9.9} if standard_logging else {}),
}
pending: Final = InteractionsAPIResponse(id="interactions/background", status=status)
await logger.async_log_success_event(kwargs, pending, now, now)
assert reservation["finalized"] is False
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(reservation["reserved_cost"])
completed: Final = pending.model_copy(update={"status": "completed", "usage": {"total_tokens": 12}})
final_kwargs: Final = {**kwargs, "standard_logging_object": {"response_cost": 0.004}}
await logger.async_log_success_event(final_kwargs, completed, now, now)
assert reservation["finalized"] is True
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(0.004)
await logger.async_log_success_event(final_kwargs, completed, now, now)
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(0.004)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "interaction_id", "polling_enabled", "has_usage"),
(
("in_progress", "interactions/background", False, False),
("queued", "interactions/background", False, False),
("in_progress", None, True, False),
("queued", "", True, False),
("completed", "interactions/background", True, False),
("failed", "interactions/background", True, False),
("cancelled", "interactions/background", True, False),
("requires_action", "interactions/background", True, False),
("in_progress", "interactions/background", True, True),
),
)
async def test_missing_cost_releases_reservation_when_no_usage_poll_will_settle_it(
spend_counter_cache: DualCache,
monkeypatch: pytest.MonkeyPatch,
status: str,
interaction_id: str | None,
polling_enabled: bool,
has_usage: bool,
) -> None:
monkeypatch.setattr(
"litellm.proxy.hooks.budget_reservation_reconcile_logger.BACKGROUND_INTERACTION_COST_POLLING_ENABLED",
polling_enabled,
)
reservation: Final = _reservation()
counter_key: Final = "spend:key:hashed"
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=reservation["reserved_cost"])
logger: Final = BudgetReservationReconcileLogger()
now: Final = datetime(2026, 1, 1, tzinfo=UTC)
await logger.async_log_success_event(
{"litellm_params": {"metadata": {"user_api_key_budget_reservation": reservation}}},
InteractionsAPIResponse(id=interaction_id, status=status, usage={"total_tokens": 12} if has_usage else None),
now,
now,
)
assert reservation["finalized"] is True
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(0.0)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("response_cost", "expected_cost"),
((0.0, 0.0), (0.004, 0.004), ("0.004", 0.004), (b"0.004", 0.004), (memoryview(b"0.004"), 0.004)),
)
async def test_reported_interaction_cost_is_settled_immediately(
spend_counter_cache: DualCache, response_cost: object, expected_cost: float
) -> None:
reservation: Final = _reservation()
counter_key: Final = "spend:key:hashed"
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=reservation["reserved_cost"])
logger: Final = BudgetReservationReconcileLogger()
now: Final = datetime(2026, 1, 1, tzinfo=UTC)
await logger.async_log_success_event(
{
"litellm_params": {"metadata": {"user_api_key_budget_reservation": reservation}},
"response_cost": response_cost,
},
InteractionsAPIResponse(id="interactions/background", status="in_progress"),
now,
now,
)
assert reservation["finalized"] is True
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(expected_cost)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"kwargs",
(
{},
{"litellm_params": {"metadata": None}},
{"litellm_params": {"metadata": "invalid"}},
{"litellm_params": {"metadata": {1: "ignored"}}},
),
)
async def test_missing_or_invalid_metadata_leaves_existing_spend_unchanged(
spend_counter_cache: DualCache, kwargs: Mapping[str, object]
) -> None:
counter_key: Final = "spend:key:hashed"
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.00255)
logger: Final = BudgetReservationReconcileLogger()
now: Final = datetime(2026, 1, 1, tzinfo=UTC)
await logger.async_log_success_event(kwargs, object(), now, now)
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(0.00255)