From f9800f1fc08f06fd79a31e28afd6f5fe0d4e3f47 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 20 Oct 2026 15:11:45 -0700 Subject: [PATCH] fix(proxy): price a 499 spend row only for the proxy's own disconnect, and never after the call already completed (#47652) * fix(proxy): price a 499 spend row only for the proxy's own disconnect, and never after the call already completed * fix(proxy): gate the zero-cost 499 row on the success log being enqueued, not merely claimed --------- Backport of #47652 to stable/1.104.x Cherry-picked from ffb7133d326e979506f17abee0412ca66de53d3a (main) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/litellm_logging.py | 11 ++ litellm/proxy/utils.py | 11 +- litellm/utils.py | 1 + tests/test_litellm/proxy/test_proxy_utils.py | 117 ++++++++++++++++++ 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 63ec230b6f7..2304189b363 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -711,6 +711,7 @@ class Logging(LiteLLMLoggingBaseClass): self._defer_async_logging: bool = False self._enqueue_deferred_logging: Callable[[], None] | None = None self._on_detached_stream_failure: Callable[[Exception], Awaitable[None]] | None = None + self._async_success_enqueued: bool = False self.shadow_eval_request_snapshot: GuardrailRequestSnapshot | None = None def set_response_timing_metrics(self, timing_metrics: Mapping[str, float]) -> None: @@ -2266,6 +2267,16 @@ class Logging(LiteLLMLoggingBaseClass): except Exception: return True + @property + def async_success_log_enqueued(self) -> bool: + """True once the success log task was actually created, not merely claimed, so a later failure raised + around it (a disconnect cancelling a still-pending guardrail task) must not bill the prompt a second + time. A claimed log parked in the deferred slot never runs once a 499 interrupts, so it stays False.""" + return self._async_success_enqueued + + def mark_async_success_log_enqueued(self) -> None: + self._async_success_enqueued = True + def has_run_logging( self, event_type: Literal["async_success", "sync_success", "async_failure", "sync_failure"], diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index d8bbb7cc808..eb07c9e61c5 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1302,8 +1302,9 @@ def _timed_out_reading_from_provider(exception: Exception) -> bool: def _client_disconnected(exception: Exception) -> bool: - """The 499 the proxy raises when the caller hung up while the provider was still working on the request.""" - return getattr(exception, "status_code", None) == LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED + """The 499 the proxy itself raises when the caller hung up while the provider was still working on the + request. A provider's own 499 arrives as a litellm exception and is a plain upstream failure.""" + return isinstance(exception, HTTPException) and exception.status_code == LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED def _provider_billed_the_failure(exception: Exception) -> bool: @@ -1325,17 +1326,19 @@ def _failure_fields_to_lift(request_data: Mapping[str, object], provider_billed: _litellm_params: Final = getattr(_logging_obj, "litellm_params", None) _base_model: Final = _get_base_model_from_metadata(model_call_details=_model_call_details) _deployment_id: Final = _logging_obj.get_router_model_id() if isinstance(_logging_obj, Logging) else None + _success_already_logged: Final = isinstance(_logging_obj, Logging) and _logging_obj.async_success_log_enqueued + _billed: Final = provider_billed and not _success_already_logged _usage_to_lift: Final = _failure_usage_to_lift( model_call_details=_model_call_details, request_body=request_data, dispatched=_first_handoff is not None, - provider_billed=provider_billed, + provider_billed=_billed, deployment_id=_deployment_id, base_model=_base_model, ) _prompt_estimate: Final = ( _estimated_prompt_usage(_model_call_details, request_data) - if provider_billed and _first_handoff is not None and not _model_call_details.get("failed_attempts_usage") + if _billed and _first_handoff is not None and not _model_call_details.get("failed_attempts_usage") else None ) _earlier_attempts: Final = ( diff --git a/litellm/utils.py b/litellm/utils.py index 6bd4c499b0c..9e93497f559 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1365,6 +1365,7 @@ def _schedule_async_success_logging( """ def _enqueue_async_logging() -> None: + logging_obj.mark_async_success_log_enqueued() asyncio.create_task( _client_async_logging_helper( logging_obj=logging_obj, diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 8064d9cee57..f03c5f57808 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -828,6 +828,123 @@ class TestPostCallFailureHookEstimatesDispatchedInputTokens: assert expected_input_cost > 0 assert request_data["response_cost"] == pytest.approx(expected_input_cost) + @pytest.mark.asyncio + async def test_upstream_499_error_is_a_plain_failure_not_a_priced_disconnect(self): + """A provider answering HTTP 499 surfaces as litellm.APIError with status_code 499. Only the + proxy's own disconnect HTTPException means the caller hung up mid-generation, so the upstream + error keeps the estimated usage at zero cost like any other post-dispatch provider error.""" + from datetime import datetime + + from litellm.constants import LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED + from litellm.types.utils import Usage + + request_data = { + "litellm_logging_obj": self._logging_obj( + { + "first_api_call_start_time": datetime(2026, 1, 1, 12, 0, 0), + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "count these input tokens please"}], + "call_type": "acompletion", + } + ), + "metadata": {}, + } + await self._run( + request_data, + original_exception=litellm.APIError( + status_code=LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED, + message="client closed request", + llm_provider="openai", + model="gpt-3.5-turbo", + ), + ) + + estimated = request_data["combined_usage_object"] + assert isinstance(estimated, Usage) + assert estimated.prompt_tokens > 0 + assert request_data["response_cost"] == 0.0 + + @pytest.mark.asyncio + async def test_client_disconnect_after_the_call_already_completed_is_not_priced_again(self): + """The LLM task finished and claimed its success log (which carries the real usage and cost) + while a during_call guardrail task was still pending, then the caller hung up. The 499 raised + for that disconnect must not price the prompt a second time.""" + from datetime import datetime + + from fastapi import HTTPException + + from litellm.constants import LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "count these input tokens please"}], + stream=False, + call_type="acompletion", + start_time=datetime(2026, 1, 1, 12, 0, 0), + litellm_call_id="completed-before-disconnect", + function_id="fn", + ) + logging_obj.model_call_details.update( + { + "first_api_call_start_time": datetime(2026, 1, 1, 12, 0, 1), + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "count these input tokens please"}], + "call_type": "acompletion", + } + ) + logging_obj.mark_async_success_log_enqueued() + request_data = {"litellm_logging_obj": logging_obj, "metadata": {}} + await self._run( + request_data, + original_exception=HTTPException( + status_code=LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED, detail="Client disconnected the request" + ), + ) + + assert request_data.get("response_cost", 0.0) == 0.0 + + @pytest.mark.asyncio + async def test_client_disconnect_with_a_claimed_but_deferred_success_log_is_still_priced(self): + """With a post_call guardrail registered the success log is claimed but parked in the deferred slot, + and a 499 raised before the flush means it never runs. Nothing else bills that request, so the + failure row keeps the estimated input cost.""" + from datetime import datetime + + from fastapi import HTTPException + + from litellm.constants import LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "count these input tokens please"}], + stream=False, + call_type="acompletion", + start_time=datetime(2026, 1, 1, 12, 0, 0), + litellm_call_id="deferred-success-never-flushed", + function_id="fn", + ) + logging_obj.model_call_details.update( + { + "first_api_call_start_time": datetime(2026, 1, 1, 12, 0, 1), + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "count these input tokens please"}], + "call_type": "acompletion", + } + ) + logging_obj._defer_async_logging = True + logging_obj._enqueue_deferred_logging = lambda: None + request_data = {"litellm_logging_obj": logging_obj, "metadata": {}} + await self._run( + request_data, + original_exception=HTTPException( + status_code=LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED, detail="Client disconnected the request" + ), + ) + + assert request_data["response_cost"] > 0.0 + @pytest.mark.asyncio async def test_client_disconnect_before_provider_handoff_is_not_priced(self): """A caller that hung up while the request was still in pre-call hooks never reached the