fix(proxy): keep the reservation when a disconnect happens while provider output is held back

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-08-08 20:39:55 +00:00
parent bb0bb48da8
commit 2d1ee3aab2
3 changed files with 76 additions and 1 deletions

View file

@ -207,6 +207,11 @@ class AgenticAnthropicStreamingIterator:
self._replay_index = 0
self._error_emitted = False
@property
def has_buffered_provider_output(self) -> bool:
"""Whether provider output was received but withheld from the client behind keepalive pings."""
return self._hold_back and bool(self._collected_bytes)
def __aiter__(self):
return self

View file

@ -40,6 +40,9 @@ from litellm.litellm_core_utils.llm_response_utils.get_headers import (
get_response_headers,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
AgenticAnthropicStreamingIterator,
)
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import can_key_call_resolved_model
from litellm.proxy.auth.auth_utils import check_response_size_is_safe
@ -95,6 +98,10 @@ _CLIENT_DISCONNECTED_ERROR_INFORMATION: Final[StandardLoggingPayloadErrorInforma
}
def _withheld_provider_output(response: object) -> bool:
return isinstance(response, AgenticAnthropicStreamingIterator) and response.has_buffered_provider_output
def _should_return_raw_model_name(request_data: dict[str, object]) -> bool:
return any(
isinstance(metadata, dict) and metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY) is True
@ -2970,7 +2977,7 @@ class ProxyBaseLLMRequestProcessing:
# only sees GeneratorExit on GC) cannot own the refund.
if not stream_completed:
client_disconnected = True
if not delivered_chunk:
if not delivered_chunk and not _withheld_provider_output(response):
from litellm.proxy.spend_tracking.budget_reservation import (
release_budget_reservation_on_cancel,
)

View file

@ -8,6 +8,9 @@ from fastapi import HTTPException
import litellm
from litellm.caching.dual_cache import DualCache
from litellm.constants import STREAM_SSE_KEEPALIVE_PING_BYTES
from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
AgenticAnthropicStreamingIterator,
)
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_EndUserTable,
@ -2376,6 +2379,11 @@ async def _reserve_for_stream(counter_cache, key_cache, proxy_logging_obj, token
return valid_token, reservation
async def _never_ending_stream():
yield b'event: message_start\ndata: {"type": "message_start"}\n\n'
await asyncio.sleep(30)
def _drive_streaming_cancel(valid_token, iterator_hook):
streaming_logging_obj = MagicMock()
streaming_logging_obj.async_post_call_streaming_iterator_hook = iterator_hook
@ -2481,6 +2489,61 @@ async def test_streaming_cancel_after_only_keepalive_pings_reconciles_to_input_c
assert reservation["finalized"] is True
@pytest.mark.asyncio
async def test_streaming_cancel_while_holding_back_provider_output_keeps_reservation(
spend_counter_state,
):
counter_cache, key_cache = spend_counter_state
proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
valid_token, reservation = await _reserve_for_stream(
counter_cache, key_cache, proxy_logging_obj, "key-cancel-held-back"
)
held_back = AgenticAnthropicStreamingIterator(
completion_stream=_never_ending_stream(),
http_handler=MagicMock(),
model="claude-haiku-4-5",
messages=[],
anthropic_messages_provider_config=MagicMock(),
anthropic_messages_optional_request_params={},
logging_obj=MagicMock(),
custom_llm_provider="anthropic",
kwargs={},
hold_back=True,
server_fulfilled_tool_names=frozenset({"headroom_retrieve"}),
ping_interval_seconds=0.01,
)
async def ping_then_cancel(user_api_key_dict, response, request_data):
yield await response.__anext__()
while not response.has_buffered_provider_output:
yield await response.__anext__()
raise asyncio.CancelledError()
streaming_logging_obj = MagicMock()
streaming_logging_obj.async_post_call_streaming_iterator_hook = ping_then_cancel
streaming_logging_obj._arelease_max_parallel_requests_on_disconnect = AsyncMock()
generator = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
response=held_back,
user_api_key_dict=valid_token,
request_data=_request_body(),
proxy_logging_obj=streaming_logging_obj,
serialize_chunk=lambda chunk: chunk,
serialize_error=lambda exc: str(exc),
)
received = []
with pytest.raises(asyncio.CancelledError):
async for chunk in generator:
received.append(chunk)
assert received and received == [STREAM_SSE_KEEPALIVE_PING_BYTES] * len(received)
assert counter_cache.in_memory_cache.get_cache(
key="spend:key:key-cancel-held-back"
) == pytest.approx(2.0)
assert reservation.get("finalized") is not True
@pytest.mark.asyncio
async def test_release_budget_reservation_on_cancel_swallows_release_errors():
# If the release itself fails (e.g. Redis unavailable) it must not escape