fix(passthrough): validate only the winning timeout value in the resolver

This commit is contained in:
mateo-berri 2026-09-18 13:07:33 -07:00
parent 58beea2275
commit 74e9fb2323
2 changed files with 38 additions and 18 deletions

View file

@ -1,18 +1,14 @@
import sys
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from pydantic import BaseModel, ConfigDict
from pydantic import TypeAdapter
DEFAULT_PASS_THROUGH_REQUEST_TIMEOUT_SECONDS: Final = 600.0
class _TimeoutFields(BaseModel):
model_config = ConfigDict(frozen=True)
stream_timeout: float | None = None
timeout: float | None = None
request_timeout: float | None = None
_SECONDS: Final = TypeAdapter(float)
_NO_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
def resolve_pass_through_request_timeout(
@ -59,20 +55,24 @@ def resolve_llm_passthrough_timeout(
any generic timeout, matching ``Router._get_stream_timeout`` on the completion route:
kwargs stream_timeout -> litellm_params stream_timeout -> router_stream_timeout, then the
non-streaming chain above.
Only the first set value is validated as seconds, so a value in a lower-precedence
field never fails the call.
"""
streaming: Final = bool((kwargs or {}).get("stream"))
request: Final = _TimeoutFields.model_validate(kwargs or {})
deployment: Final = _TimeoutFields.model_validate(litellm_params or {})
request: Final = kwargs if kwargs is not None else _NO_PARAMS
deployment: Final = litellm_params if litellm_params is not None else _NO_PARAMS
stream_candidates: Final = (
(request.stream_timeout, deployment.stream_timeout, router_stream_timeout) if streaming else ()
(request.get("stream_timeout"), deployment.get("stream_timeout"), router_stream_timeout)
if request.get("stream")
else ()
)
candidates: Final = (
*stream_candidates,
request.timeout,
request.request_timeout,
deployment.timeout,
deployment.request_timeout,
request.get("timeout"),
request.get("request_timeout"),
deployment.get("timeout"),
deployment.get("request_timeout"),
router_timeout,
)
resolved: Final = next((float(val) for val in candidates if val is not None), None)
return resolved if resolved is not None else resolve_pass_through_request_timeout()
winner: Final = next((val for val in candidates if val is not None), None)
return resolve_pass_through_request_timeout() if winner is None else _SECONDS.validate_python(winner)

View file

@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import Request, Response, UploadFile
from pydantic import ValidationError
from starlette.datastructures import FormData, Headers, QueryParams
from starlette.datastructures import UploadFile as StarletteUploadFile
@ -1189,6 +1190,25 @@ def test_resolve_llm_passthrough_timeout_reads_stream_by_truthiness(stream: obje
)
@pytest.mark.parametrize(
"kwargs, litellm_params, expected",
[
({"stream": True, "stream_timeout": 1800, "timeout": httpx.Timeout(30.0)}, {}, 1800.0),
({"stream": False}, {"stream_timeout": httpx.Timeout(30.0), "timeout": 90}, 90.0),
({"timeout": 45}, {"request_timeout": httpx.Timeout(30.0)}, 45.0),
],
)
def test_resolve_llm_passthrough_timeout_validates_only_the_winning_value(
kwargs: dict[str, object], litellm_params: dict[str, object], expected: float
):
assert resolve_llm_passthrough_timeout(kwargs=kwargs, litellm_params=litellm_params) == expected
def test_resolve_llm_passthrough_timeout_rejects_a_non_numeric_winner():
with pytest.raises(ValidationError):
resolve_llm_passthrough_timeout(kwargs={"timeout": httpx.Timeout(30.0)})
@pytest.mark.asyncio
async def test_pass_through_request_uses_resolved_timeout():
with patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_proxy_logging: