From 9153bc6236320b3e92f1fb71b2ab81fbe220f340 Mon Sep 17 00:00:00 2001 From: Raphael Zanarelli Date: Mon, 31 Aug 2026 21:41:18 -0300 Subject: [PATCH] fix(openai): do not pass ssl_verify to extra_body and apply per-call TLS config to HTTP client --- litellm/llms/openai/common_utils.py | 12 +++- litellm/llms/openai/openai.py | 20 +++++- litellm/main.py | 6 +- litellm/utils.py | 2 + .../llms/openai/test_openai_ssl_verify.py | 68 +++++++++++++++++++ tests/test_litellm_ssl_verify.py | 50 ++++++++++++++ 6 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_openai_ssl_verify.py create mode 100644 tests/test_litellm_ssl_verify.py diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 2db6d78a218..a6c84ae1bf6 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -1,3 +1,5 @@ +from litellm.types.llms.custom_http import VerifyTypes + """ Common helpers / utils across al OpenAI endpoints """ @@ -278,6 +280,7 @@ class BaseOpenAILLM: "organization", "api_base", "workload_identity_config", + "ssl_verify", ) openai_client_fields: Final = ( BaseOpenAILLM.get_openai_client_initialization_param_fields(client_type=client_type) @@ -303,6 +306,7 @@ class BaseOpenAILLM: @staticmethod def _get_async_http_client( shared_session: Optional["ClientSession"] = None, + ssl_verify: VerifyTypes | None = None, ) -> httpx.AsyncClient | None: if litellm.aclient_session is not None: return litellm.aclient_session @@ -313,7 +317,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) transport: Final = AsyncHTTPHandler._create_async_transport( ssl_context=(ssl_config if isinstance(ssl_config, ssl.SSLContext) else None), ssl_verify=ssl_config if isinstance(ssl_config, bool) else None, @@ -328,7 +332,9 @@ class BaseOpenAILLM: ) @staticmethod - def _get_sync_http_client() -> httpx.Client | None: + def _get_sync_http_client( + ssl_verify: VerifyTypes | None = None, + ) -> httpx.Client | None: if litellm.client_session is not None: return litellm.client_session @@ -338,7 +344,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) return httpx.Client( verify=ssl_config, diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index edc8d64d9c2..cdd22579245 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -1,3 +1,4 @@ +from litellm.types.llms.custom_http import VerifyTypes import time import types from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping @@ -382,6 +383,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization: str | None = None, client: OpenAI | AsyncOpenAI | None = None, shared_session: Optional["ClientSession"] = None, + ssl_verify: VerifyTypes | None = None, ) -> OpenAI | AsyncOpenAI | None: workload_identity_config: Final = resolve_openai_workload_identity_config(api_key=api_key, api_base=api_base) client_initialization_params: Final[dict] = locals() @@ -400,7 +402,10 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): if isinstance(cached_client, OpenAI) or isinstance(cached_client, AsyncOpenAI): return cached_client if is_async: - async_http_client: Final = OpenAIChatCompletion._get_async_http_client(shared_session=shared_session) + async_http_client: Final = OpenAIChatCompletion._get_async_http_client( + shared_session=shared_session, + ssl_verify=ssl_verify, + ) http_client: httpx.Client | httpx.AsyncClient | None = async_http_client _new_client: OpenAI | AsyncOpenAI = ( AsyncOpenAI( @@ -422,7 +427,9 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): ) ) else: - sync_http_client: Final = OpenAIChatCompletion._get_sync_http_client() + sync_http_client: Final = OpenAIChatCompletion._get_sync_http_client( + ssl_verify=ssl_verify, + ) http_client = sync_http_client _new_client = ( OpenAI( @@ -773,10 +780,12 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, stream_options=stream_options, + ssl_verify=litellm_params.get("ssl_verify", None) if litellm_params else None, ) else: if not isinstance(max_retries, int): raise OpenAIError(status_code=422, message="max retries must be an int") + ssl_verify: Final = litellm_params.get("ssl_verify", None) if litellm_params else None openai_client: OpenAI = self._get_openai_client( is_async=False, api_key=api_key, @@ -786,6 +795,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=ssl_verify, ) ## LOGGING @@ -917,6 +927,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): ) for _ in range(2): # if call fails due to alternating messages, retry with reformatted message try: + ssl_verify: Final = litellm_params.get("ssl_verify", None) if litellm_params else None openai_aclient: AsyncOpenAI = self._get_openai_client( is_async=True, api_key=api_key, @@ -927,6 +938,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=ssl_verify, ) ## LOGGING @@ -1022,6 +1034,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=None, headers=None, stream_options: dict | None = None, + ssl_verify: VerifyTypes | None = None, ): data["stream"] = True data.update(self.get_stream_options(stream_options=stream_options, api_base=api_base)) @@ -1035,6 +1048,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( @@ -1097,6 +1111,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): data.update(self.get_stream_options(stream_options=stream_options, api_base=api_base)) for _ in range(2): try: + ssl_verify: Final = litellm_params.get("ssl_verify", None) if litellm_params else None openai_aclient: AsyncOpenAI = self._get_openai_client( is_async=True, api_key=api_key, @@ -1107,6 +1122,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( diff --git a/litellm/main.py b/litellm/main.py index 2929790f2bd..5ec22f6348d 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5179,7 +5179,7 @@ def completion( context_window_fallback_dict: Final = kwargs.get("context_window_fallback_dict", None) organization: Final = kwargs.get("organization", None) ### VERIFY SSL ### - ssl_verify: Final = kwargs.get("ssl_verify", None) + ssl_verify: Final = kwargs.pop("ssl_verify", None) ### CUSTOM MODEL COST ### input_cost_per_token: Final = kwargs.get("input_cost_per_token", None) output_cost_per_token: Final = kwargs.get("output_cost_per_token", None) @@ -7088,7 +7088,9 @@ def embedding( optional_params=optional_params, client=client, aembedding=aembedding, - litellm_params={"ssl_verify": kwargs.get("ssl_verify", None)}, + litellm_params={ + "ssl_verify": kwargs.pop("ssl_verify", None) + }, # mutable-ok: litellm_params passed to embedding handler ) elif custom_llm_provider == "perplexity": response = base_llm_http_handler.embedding( diff --git a/litellm/utils.py b/litellm/utils.py index 239673a20d9..1297ea16855 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4742,6 +4742,8 @@ def add_provider_specific_params_to_optional_params( extra_body: Final = dict(passed_params.pop("extra_body", None) or {}) for k in passed_params: if k not in openai_params and passed_params[k] is not None: + if k in ("ssl_verify",): + continue extra_body[k] = passed_params[k] if not isinstance(optional_params.get("extra_body"), dict): optional_params["extra_body"] = {} diff --git a/tests/test_litellm/llms/openai/test_openai_ssl_verify.py b/tests/test_litellm/llms/openai/test_openai_ssl_verify.py new file mode 100644 index 00000000000..d976f026ea7 --- /dev/null +++ b/tests/test_litellm/llms/openai/test_openai_ssl_verify.py @@ -0,0 +1,68 @@ +from unittest.mock import MagicMock, patch +import httpx +import litellm +from litellm.llms.openai.common_utils import BaseOpenAILLM +from litellm.utils import get_optional_params, add_provider_specific_params_to_optional_params + + +def test_ssl_verify_not_in_extra_body(): + """ + Ensure ssl_verify is NOT dumped into extra_body for openai and openai-compatible providers. + Issue #38178: ssl_verify was leaking into extra_body payload sent to OpenAI-compatible endpoints. + """ + optional_params = {} + passed_params = { + "ssl_verify": "/custom/path/ca.pem", + "temperature": 0.7, + "custom_param": "value", + } + + result = add_provider_specific_params_to_optional_params( + optional_params=optional_params, + passed_params=passed_params, + custom_llm_provider="openai", + openai_params=["temperature"], + ) + + extra_body = result.get("extra_body", {}) + assert "ssl_verify" not in extra_body + assert extra_body.get("custom_param") == "value" + + +def test_get_sync_http_client_with_ssl_verify(): + """ + Verify _get_sync_http_client applies per-call ssl_verify to httpx.Client(verify=...). + """ + client_false = BaseOpenAILLM._get_sync_http_client(ssl_verify=False) + assert client_false is not None + + +def test_get_async_http_client_with_ssl_verify(): + """ + Verify _get_async_http_client applies per-call ssl_verify to httpx.AsyncClient(verify=...). + """ + client_false = BaseOpenAILLM._get_async_http_client(ssl_verify=False) + assert client_false is not None + + +def test_cache_key_differs_by_ssl_verify(): + """ + Verify cache keys differ when ssl_verify differs to prevent client poisoning across CAs. + """ + params_ca1 = { + "api_key": "sk-1234", + "is_async": True, + "ssl_verify": "/path/ca1.pem", + } + params_ca2 = { + "api_key": "sk-1234", + "is_async": True, + "ssl_verify": "/path/ca2.pem", + } + + key1 = BaseOpenAILLM.get_openai_client_cache_key(params_ca1, "openai") + key2 = BaseOpenAILLM.get_openai_client_cache_key(params_ca2, "openai") + + assert key1 != key2 + assert "ssl_verify=/path/ca1.pem" in key1 + assert "ssl_verify=/path/ca2.pem" in key2 diff --git a/tests/test_litellm_ssl_verify.py b/tests/test_litellm_ssl_verify.py new file mode 100644 index 00000000000..fd6ddc33c6b --- /dev/null +++ b/tests/test_litellm_ssl_verify.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import patch, MagicMock +import litellm +import httpx + +@pytest.mark.asyncio +async def test_ssl_verify_false(): + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.post.return_value = MagicMock(status_code=200, json=lambda: {"choices": [{"message": {"content": "hello"}}]}) + + response = await litellm.acompletion( + model="openai/gpt-3.5-turbo", + messages=[{"role": "user", "content": "hi"}], + ssl_verify=False, + api_key="sk-123" + ) + # Check that AsyncClient was initialized with verify=False + called_kwargs = mock_client.call_args[1] + assert called_kwargs.get("verify") is False + +@pytest.mark.asyncio +async def test_ssl_verify_custom_ca(): + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.post.return_value = MagicMock(status_code=200, json=lambda: {"choices": [{"message": {"content": "hello"}}]}) + + custom_ca_path = "/path/to/custom-ca.pem" + response = await litellm.acompletion( + model="openai/gpt-3.5-turbo", + messages=[{"role": "user", "content": "hi"}], + ssl_verify=custom_ca_path, + api_key="sk-123" + ) + # Check that AsyncClient was initialized with verify=custom_ca_path + called_kwargs = mock_client.call_args[1] + assert called_kwargs.get("verify") == custom_ca_path + +@pytest.mark.asyncio +async def test_ssl_verify_default(): + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.post.return_value = MagicMock(status_code=200, json=lambda: {"choices": [{"message": {"content": "hello"}}]}) + + response = await litellm.acompletion( + model="openai/gpt-3.5-turbo", + messages=[{"role": "user", "content": "hi"}], + api_key="sk-123" + ) + # By default, should not pass verify=False (usually defaults to True or SSLContext depending on get_ssl_configuration) + called_kwargs = mock_client.call_args[1] + verify_arg = called_kwargs.get("verify") + assert verify_arg is not False and verify_arg is not None