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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013FDwtkaNTifWAP4W7JZavE
This commit is contained in:
narmstrong-berri 2026-11-06 07:57:40 +00:00
parent 1d46ade6da
commit 788c4dae51
No known key found for this signature in database
2 changed files with 19 additions and 0 deletions

View file

@ -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):

View file

@ -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