From 60e6545c75afbb54c2317eb42e9c8c7c06ce760e Mon Sep 17 00:00:00 2001 From: Praveena Ganesan Date: Wed, 26 Aug 2026 03:25:38 +0530 Subject: [PATCH] fix(openai): apply ssl_verify to the TLS client instead of leaking it into extra_body ssl_verify was not listed in all_litellm_params, so the OpenAI param builder swept it into the request body's extra_body and OpenAI-compatible endpoints rejected the request. The OpenAI SDK path also built its httpx client from the global SSL config only, so a per-request CA bundle never reached TLS. Register ssl_verify as a LiteLLM-level param and thread it through _get_openai_client into the sync and async httpx clients, keyed into the client cache so deployments with different CA bundles don't share a client. Fixes #38178 --- litellm/llms/openai/common_utils.py | 10 +- litellm/llms/openai/openai.py | 15 ++- litellm/types/utils.py | 1 + .../llms/openai/test_ssl_verify_not_leaked.py | 120 ++++++++++++++++++ 4 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 2db6d78a218..e5dfd1e5e64 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -278,6 +278,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 +304,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 @@ -313,7 +315,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) 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 +330,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 @@ -338,7 +342,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 5f04ebe0c01..357721f7399 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 @@ -382,6 +383,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: 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,7 @@ 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,6 +778,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): @@ -786,6 +792,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING @@ -928,6 +935,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING @@ -1024,6 +1032,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)) @@ -1037,6 +1046,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( @@ -1109,6 +1119,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 00c55b35182..d71c7afbc35 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3747,6 +3747,7 @@ all_litellm_params = ( "prompt_version", "prompt_environment", "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