diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py index 19149da0316..f9d27cd1f0b 100644 --- a/litellm/litellm_core_utils/get_supported_openai_params.py +++ b/litellm/litellm_core_utils/get_supported_openai_params.py @@ -53,6 +53,16 @@ def get_supported_openai_params( if provider_config and request_type == "chat_completion": supported_params = provider_config.get_supported_openai_params(model=model) + allowed_openai_params = getattr(provider_config, "allowed_openai_params", None) + supported_params = supported_params or [] + allowed_openai_params = allowed_openai_params or [] + supported_params.extend(allowed_openai_params) + # ``max_retries`` is supported for providers with actual retry wiring + # (OpenAI/Azure retry at the SDK layer; anthropic retries at LiteLLM's + # httpx transport layer when ``max_retries`` is set). + providers_with_retry_support = {"openai", "azure", "anthropic"} + if custom_llm_provider in providers_with_retry_support and "max_retries" not in supported_params: + supported_params.append("max_retries") if base_model and base_model != model: base_model_params = provider_config.get_supported_openai_params(model=base_model) supported_params = list(dict.fromkeys([*supported_params, *base_model_params])) diff --git a/litellm/utils.py b/litellm/utils.py index f27e56eea4d..9963345a568 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -3841,11 +3841,8 @@ def get_optional_params( supported_params = supported_params or [] allowed_openai_params = allowed_openai_params or [] supported_params.extend(allowed_openai_params) - # ``max_retries`` is supported across all providers (OpenAI/Azure retry at - # the SDK layer; non-OpenAI providers retry at LiteLLM's httpx transport - # layer when ``max_retries`` is set). Declare it supported everywhere so it - # is no longer silently dropped for non-OpenAI providers. - if "max_retries" not in supported_params: + providers_with_retry_support = {"openai", "azure", "anthropic"} + if custom_llm_provider in providers_with_retry_support and "max_retries" not in supported_params: supported_params.append("max_retries") _check_valid_arg( diff --git a/tests/litellm/llms/anthropic/test_anthropic_max_retries.py b/tests/litellm/llms/anthropic/test_anthropic_max_retries.py index 1f12d668466..d09158286dd 100644 --- a/tests/litellm/llms/anthropic/test_anthropic_max_retries.py +++ b/tests/litellm/llms/anthropic/test_anthropic_max_retries.py @@ -2,10 +2,9 @@ Regression tests for `max_retries` support on non-OpenAI providers. `max_retries` was previously silently dropped for every provider except -OpenAI/Azure (see the TODO in `litellm/utils.py`). These tests lock in the -fix that: +OpenAI/Azure. These tests lock in the fix that: -1. Declares `max_retries` as a supported param for all providers. +1. Declares `max_retries` as a supported param for providers with retry wiring (openai, azure, anthropic). 2. Maps it through `AnthropicConfig` without leaking it into the request body. 3. Wires it to LiteLLM's httpx transport so transient connection errors are retried. @@ -15,7 +14,12 @@ import httpx import pytest from litellm.llms.anthropic.chat.transformation import AnthropicConfig -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, + HTTPHandler, + get_async_httpx_client, + _get_httpx_client, +) class TestAnthropicMaxRetriesSupported: @@ -67,10 +71,92 @@ class TestMaxRetriesHttpTransport: assert isinstance(transport, httpx.HTTPTransport) assert getattr(transport._pool, "_retries", 0) == 2 - def test_default_handler_does_not_change_transport(self): - # Without max_retries the default (aiohttp) transport path is untouched. + def test_async_handler_retry_transport_with_force_ipv4(self): + import litellm + + original_force_ipv4 = litellm.force_ipv4 + litellm.force_ipv4 = True + litellm.disable_aiohttp_transport = True + try: + transport = AsyncHTTPHandler._create_async_transport(max_retries=3) + assert isinstance(transport, httpx.AsyncHTTPTransport) + assert getattr(transport._pool, "_retries", 0) == 3 + assert transport._pool._local_address == "0.0.0.0" + finally: + litellm.force_ipv4 = original_force_ipv4 + litellm.disable_aiohttp_transport = False + + def test_sync_handler_retry_transport_with_force_ipv4(self): + import litellm + + original_force_ipv4 = litellm.force_ipv4 + litellm.force_ipv4 = True + litellm.disable_aiohttp_transport = True + try: + handler = HTTPHandler(max_retries=3, timeout=5) + transport = handler.client._transport + assert isinstance(transport, httpx.HTTPTransport) + assert getattr(transport._pool, "_retries", 0) == 3 + assert transport._pool._local_address == "0.0.0.0" + finally: + litellm.force_ipv4 = original_force_ipv4 + litellm.disable_aiohttp_transport = False + handler.close() + + def test_async_handler_force_ipv4_without_max_retries(self): + import litellm + + original_force_ipv4 = litellm.force_ipv4 + litellm.force_ipv4 = True + litellm.disable_aiohttp_transport = True + try: + transport = AsyncHTTPHandler._create_async_transport() + assert isinstance(transport, httpx.AsyncHTTPTransport) + assert transport._pool._local_address == "0.0.0.0" + finally: + litellm.force_ipv4 = original_force_ipv4 + litellm.disable_aiohttp_transport = False + + def test_default_handler_does_not_use_retry_transport(self): + # Without max_retries we do not use the retry transport. handler = AsyncHTTPHandler() - assert not isinstance(handler.client._transport, httpx.AsyncHTTPTransport) + transport = handler.client._transport + + # Check if aiohttp transport is available and enabled + aiohttp_enabled = AsyncHTTPHandler._should_use_aiohttp_transport() + + # Import the aiohttp transport type for checking + try: + from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport + + HAS_AIOHTTP_TRANSPORT = True + except ImportError: + HAS_AIOHTTP_TRANSPORT = False + LiteLLMAiohttpTransport = None + + if aiohttp_enabled and HAS_AIOHTTP_TRANSPORT: + # When aiohttp is available and enabled, we should get the aiohttp transport + # which has no built-in retry mechanism in LiteLLM + assert isinstance(transport, LiteLLMAiohttpTransport) + else: + # When aiohttp is not available/disabled, we should get an httpx transport + # with explicit retries=0 (no retry) + assert isinstance(transport, httpx.AsyncHTTPTransport) + assert getattr(transport._pool, "_retries", 0) == 0 + + def test_async_handler_transport_none_when_aiohttp_disabled_and_ipv4_off(self): + import litellm + + original_force_ipv4 = litellm.force_ipv4 + original_disable = litellm.disable_aiohttp_transport + litellm.force_ipv4 = False + litellm.disable_aiohttp_transport = True + try: + transport = AsyncHTTPHandler._create_async_transport() + assert transport is None + finally: + litellm.force_ipv4 = original_force_ipv4 + litellm.disable_aiohttp_transport = original_disable class TestMaxRetriesValidation: @@ -85,3 +171,54 @@ class TestMaxRetriesValidation: supported = get_supported_openai_params(model=model, custom_llm_provider=provider) assert "max_retries" in supported + + def test_non_wired_provider_still_raises_for_max_retries(self): + """ + Regression test: providers without retry wiring should still raise + UnsupportedParamsError when max_retries is passed via completion(). + """ + from litellm.utils import get_supported_openai_params + + # Cohere doesn't have actual retry wiring yet + # get_supported_openai_params should NOT include max_retries for cohere + supported_params = get_supported_openai_params(model="command", custom_llm_provider="cohere") + assert "max_retries" not in supported_params, "max_retries should not be in supported params for cohere" + + +class TestMaxRetriesHandlerPaths: + def test_get_async_httpx_client_with_max_retries(self): + """Test that get_async_httpx_client creates client with max_retries when provided.""" + from litellm.types.utils import LlmProviders + import asyncio + + client = get_async_httpx_client(llm_provider=LlmProviders.ANTHROPIC, params={"max_retries": 5}) + try: + transport = client.client._transport + assert isinstance(transport, httpx.AsyncHTTPTransport) + assert getattr(transport._pool, "_retries", 0) == 5 + finally: + asyncio.run(client.close()) + + def test_get_httpx_client_with_max_retries(self): + """Test that _get_httpx_client creates client with max_retries when provided.""" + handler = _get_httpx_client(params={"max_retries": 4, "timeout": 30}) + try: + transport = handler.client._transport + assert isinstance(transport, httpx.HTTPTransport) + assert getattr(transport._pool, "_retries", 0) == 4 + finally: + handler.close() + + def test_get_async_httpx_client_without_max_retries(self): + """Test that get_async_httpx_client creates client without max_retries when not provided.""" + from litellm.types.utils import LlmProviders + from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport + import asyncio + + client = get_async_httpx_client(llm_provider=LlmProviders.ANTHROPIC, params={}) + try: + transport = client.client._transport + # Should be LiteLLMAiohttpTransport when available (default) + assert isinstance(transport, LiteLLMAiohttpTransport) + finally: + asyncio.run(client.close())