diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 1b1ab80e85d..24e354d93cf 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -268,6 +268,7 @@ class BaseOpenAILLM: "max_retries", "organization", "api_base", + "ssl_verify", ) openai_client_fields: Final = ( BaseOpenAILLM.get_openai_client_initialization_param_fields(client_type=client_type) @@ -293,6 +294,7 @@ class BaseOpenAILLM: @staticmethod def _get_async_http_client( shared_session: Optional["ClientSession"] = None, + ssl_verify: bool | str | ssl.SSLContext | None = None, ) -> httpx.AsyncClient | None: if litellm.aclient_session is not None: return litellm.aclient_session @@ -303,7 +305,7 @@ class BaseOpenAILLM: return httpx.AsyncClient(transport=MockOpenAITransport()) # Get unified SSL configuration - ssl_config: Final = get_ssl_configuration() + ssl_config: Final = get_ssl_configuration(ssl_verify=ssl_verify) return httpx.AsyncClient( verify=ssl_config, @@ -316,7 +318,9 @@ class BaseOpenAILLM: ) @staticmethod - def _get_sync_http_client() -> httpx.Client | None: + def _get_sync_http_client( + ssl_verify: bool | str | ssl.SSLContext | None = None, + ) -> httpx.Client | None: if litellm.client_session is not None: return litellm.client_session @@ -326,7 +330,7 @@ class BaseOpenAILLM: return httpx.Client(transport=MockOpenAITransport()) # Get unified SSL configuration - ssl_config: Final = get_ssl_configuration() + ssl_config: Final = get_ssl_configuration(ssl_verify=ssl_verify) return httpx.Client( verify=ssl_config, diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index ee0efb88a38..d3608f5a60e 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -1,3 +1,4 @@ +import ssl import time import types from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping @@ -347,6 +348,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization: str | None = None, client: OpenAI | AsyncOpenAI | None = None, shared_session: Optional["ClientSession"] = None, + ssl_verify: bool | str | ssl.SSLContext | None = None, ) -> OpenAI | AsyncOpenAI | None: client_initialization_params: Final[dict] = locals() if client is None: @@ -364,9 +366,12 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): if isinstance(cached_client, OpenAI) or isinstance(cached_client, AsyncOpenAI): return cached_client http_client: Final[httpx.Client | httpx.AsyncClient | None] = ( - OpenAIChatCompletion._get_async_http_client(shared_session=shared_session) + OpenAIChatCompletion._get_async_http_client( + shared_session=shared_session, + ssl_verify=ssl_verify, + ) if is_async - else OpenAIChatCompletion._get_sync_http_client() + else OpenAIChatCompletion._get_sync_http_client(ssl_verify=ssl_verify) ) if is_async: _new_client: OpenAI | AsyncOpenAI = AsyncOpenAI( @@ -716,6 +721,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, stream_options=stream_options, + ssl_verify=litellm_params.get("ssl_verify"), ) else: if not isinstance(max_retries, int): @@ -729,6 +735,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING @@ -870,6 +877,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING @@ -965,6 +973,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=None, headers=None, stream_options: dict | None = None, + ssl_verify: bool | str | ssl.SSLContext | None = None, ): data["stream"] = True data.update(self.get_stream_options(stream_options=stream_options, api_base=api_base)) @@ -978,6 +987,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( @@ -1050,6 +1060,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING logging_obj.pre_call( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 73f46bd2181..3c2d3cb699b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3515,6 +3515,7 @@ all_litellm_params = ( "provider_specific_header", "prompt_version", "api_base", + "ssl_verify", "force_timeout", "logger_fn", "verbose", diff --git a/tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py b/tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py new file mode 100644 index 00000000000..813f049812e --- /dev/null +++ b/tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py @@ -0,0 +1,120 @@ +""" +Regression test for issue #38178. + +``ssl_verify`` is a LiteLLM-level TLS setting, not a provider body param. It has to +configure the httpx client backing the OpenAI SDK client, and it must never be swept +into ``extra_body``: OpenAI-compatible endpoints reject unknown body fields with a 400. +""" + +from pathlib import Path +from unittest.mock import MagicMock + +import certifi +import pytest + +import litellm +from litellm.llms.custom_httpx.http_handler import get_ssl_configuration +from litellm.llms.openai.openai import OpenAIChatCompletion +from litellm.types.utils import all_litellm_params +from litellm.utils import get_non_default_completion_params + + +@pytest.fixture +def ca_bundle(tmp_path: Path) -> str: + """A real, loadable CA bundle at a path that is not certifi's default.""" + bundle = tmp_path / "corporate-ca.crt" + bundle.write_bytes(Path(certifi.where()).read_bytes()) + return str(bundle) + + +def test_ssl_verify_is_a_known_litellm_param(): + assert "ssl_verify" in all_litellm_params + + +def test_ssl_verify_not_forwarded_as_provider_param(ca_bundle: str): + forwarded = get_non_default_completion_params({"ssl_verify": ca_bundle, "temperature": 0.5}) + assert "ssl_verify" not in forwarded + + +def test_completion_does_not_leak_ssl_verify_into_provider_request_body(ca_bundle: str): + mock_response = MagicMock() + mock_response.model_dump.return_value = { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + mock_raw_response = MagicMock() + mock_raw_response.headers = {} + mock_raw_response.parse.return_value = mock_response + + mock_client = MagicMock() + mock_client.chat.completions.with_raw_response.create.return_value = mock_raw_response + + litellm.completion( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "hi"}], + ssl_verify=ca_bundle, + api_key="sk-test", + client=mock_client, + ) + + create_kwargs = mock_client.chat.completions.with_raw_response.create.call_args.kwargs + assert "ssl_verify" not in create_kwargs + assert "ssl_verify" not in (create_kwargs.get("extra_body") or {}) + + +def test_sync_openai_client_uses_ssl_verify_ca_bundle(ca_bundle: str): + handler = OpenAIChatCompletion() + client = handler._get_openai_client( + is_async=False, + api_key="sk-test", + api_base="https://private.example.com/v1", + max_retries=0, + ssl_verify=ca_bundle, + ) + assert client is not None + pool = client._client._transport._pool + assert pool._ssl_context is get_ssl_configuration(ssl_verify=ca_bundle) + assert pool._ssl_context is not get_ssl_configuration() + + +@pytest.mark.asyncio +async def test_async_openai_client_uses_ssl_verify_ca_bundle(ca_bundle: str): + handler = OpenAIChatCompletion() + client = handler._get_openai_client( + is_async=True, + api_key="sk-test", + api_base="https://private.example.com/v1", + max_retries=0, + ssl_verify=ca_bundle, + ) + assert client is not None + session = client._client._transport._client_factory() + try: + assert session.connector._ssl is get_ssl_configuration(ssl_verify=ca_bundle) + assert session.connector._ssl is not get_ssl_configuration() + finally: + await session.close() + + +def test_openai_client_cache_is_keyed_on_ssl_verify(ca_bundle: str): + handler = OpenAIChatCompletion() + shared_args = { + "is_async": False, + "api_key": "sk-test", + "api_base": "https://private.example.com/v1", + "max_retries": 0, + } + with_ca = handler._get_openai_client(**shared_args, ssl_verify=ca_bundle) + without_ca = handler._get_openai_client(**shared_args) + assert with_ca is not without_ca