From 788c4dae518fc8d5c3da74fc26dac472f1022c82 Mon Sep 17 00:00:00 2001 From: narmstrong-berri Date: Fri, 6 Nov 2026 07:57:40 +0000 Subject: [PATCH] fix(messages): bill a native read timeout's prompt on the failure row like python The proxy bills the estimated prompt of a failed call only when the Timeout was caused by an httpx ReadTimeout. The native timeout carried no transport cause, so its failure SpendLogs row and spend counters showed 0 where Python bills the prompt. The native timeout now chains the same httpx timeout Python's client raises. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013FDwtkaNTifWAP4W7JZavE --- litellm/rust_bridge/host/routes/messages.py | 9 +++++++++ tests/unit/rust_bridge/host/routes/test_messages.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/litellm/rust_bridge/host/routes/messages.py b/litellm/rust_bridge/host/routes/messages.py index c8b53097f46..d56482d45c4 100644 --- a/litellm/rust_bridge/host/routes/messages.py +++ b/litellm/rust_bridge/host/routes/messages.py @@ -128,6 +128,10 @@ _exception_type: Final = cast( # cast-ok: the legacy public exception mapper is Callable[..., Exception], exception_mapping_utils.exception_type ) _BODY_READ_TIMEOUT: Final = "Timeout on reading data from socket" # aiohttp's SocketTimeoutError text +_TRANSPORT_TIMEOUTS: Final[Mapping[object, type[httpx.TimeoutException]]] = { + "Connection": httpx.ConnectTimeout, + "Read": httpx.ReadTimeout, +} _MISSING_KEY: Final = ( "Missing Anthropic API Key - A call is being made to anthropic but no key is set " "either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` " @@ -232,6 +236,11 @@ def map_failure(error: Exception, request: Mapping[str, object], request_provide # Python reads a stream's body outside its HTTP client's error mapping, so the transport's # own timeout reaches the caller unmapped. return httpx.ReadTimeout(_BODY_READ_TIMEOUT) + transport_timeout: Final = _TRANSPORT_TIMEOUTS.get(getattr(error, "messages_timeout", None)) + if transport_timeout is not None: + # Python's `litellm.Timeout` is raised while handling httpx's own timeout, which the proxy's + # failure spend reads to bill a read timeout's prompt. + error.__context__ = transport_timeout(str(error)) if getattr(error, "messages_late_rejection", False): _log_resolved_call(request, request_provider) if getattr(error, "messages_request_error", False): diff --git a/tests/unit/rust_bridge/host/routes/test_messages.py b/tests/unit/rust_bridge/host/routes/test_messages.py index 9d8b09dd1c4..f916a906c14 100644 --- a/tests/unit/rust_bridge/host/routes/test_messages.py +++ b/tests/unit/rust_bridge/host/routes/test_messages.py @@ -207,6 +207,16 @@ def test_a_native_read_timeout_maps_to_the_public_timeout_with_python_text() -> assert "Read timed out. Timeout passed=1.0" in str(mapped) +@pytest.mark.parametrize(("phase", "billed"), (("Read", True), ("Connection", False))) +def test_a_native_timeout_is_billed_on_the_failure_row_as_python_bills_it(phase: str, billed: bool) -> None: + from litellm.proxy.utils import _provider_billed_the_failure + + mapped: Final = messages_host.map_failure(_marked(phase), {"model": _ADAPTIVE_OPUS, "timeout": 1.0}, "anthropic") + + assert isinstance(mapped, litellm.Timeout) + assert _provider_billed_the_failure(mapped) is billed + + def test_an_upstream_status_maps_through_the_provider_error_class() -> None: upstream: Final = ValueError(429, "slow down") upstream.headers = [("retry-after", "3")] # pyright: ignore[reportAttributeAccessIssue] # set by the native host