From bf6a0e66f31846934aee8a51c1d97ee109ea77ae Mon Sep 17 00:00:00 2001 From: King Star Date: Tue, 25 Aug 2026 15:43:51 +0800 Subject: [PATCH] fix(openai): honor per-call SSL verification --- litellm/llms/openai/common_utils.py | 9 +- litellm/llms/openai/openai.py | 14 ++- .../llms/openai/test_openai_common_utils.py | 91 ++++++++++++++++++- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 1b1ab80e85d..30c8dc0468f 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -32,6 +32,7 @@ from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, get_ssl_configuration, ) +from litellm.types.llms.custom_http import VerifyTypes def _get_client_init_params(cls: type) -> tuple[str, ...]: @@ -268,6 +269,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 +295,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 @@ -303,7 +306,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 +319,7 @@ 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 @@ -326,7 +329,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..3aec32faf83 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -26,6 +26,7 @@ from litellm.litellm_core_utils.logging_utils import speech_request_body, track_ from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.llms.bedrock.chat.invoke_handler import MockResponseIterator +from litellm.types.llms.custom_http import VerifyTypes from litellm.types.utils import ( EmbeddingResponse, ImageResponse, @@ -347,6 +348,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: client_initialization_params: Final[dict] = locals() if client is None: @@ -364,9 +366,9 @@ 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( @@ -693,6 +695,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): drop_params=drop_params, fake_stream=fake_stream, shared_session=shared_session, + ssl_verify=litellm_params.get("ssl_verify"), ) data = provider_config.transform_request( @@ -716,6 +719,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 +733,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING @@ -849,6 +854,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): stream_options: dict | None = None, fake_stream: bool = False, shared_session: Optional["ClientSession"] = None, + ssl_verify: VerifyTypes | None = None, ): response = None data = await provider_config.async_transform_request( @@ -870,6 +876,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=ssl_verify, ) ## LOGGING @@ -965,6 +972,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)) @@ -978,6 +986,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( @@ -1050,6 +1059,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/tests/test_litellm/llms/openai/test_openai_common_utils.py b/tests/test_litellm/llms/openai/test_openai_common_utils.py index 3ae29e411e8..6262b097013 100644 --- a/tests/test_litellm/llms/openai/test_openai_common_utils.py +++ b/tests/test_litellm/llms/openai/test_openai_common_utils.py @@ -1,10 +1,9 @@ -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import httpx import openai import pytest - import litellm from litellm.litellm_core_utils.token_counter import token_counter from litellm.llms.openai.common_utils import BaseOpenAILLM @@ -174,6 +173,94 @@ def test_get_openai_client_cache_key(client_type): assert "api_key=sk-test" in key +def test_get_openai_client_cache_key_includes_ssl_verify(): + first_key = BaseOpenAILLM.get_openai_client_cache_key( + client_initialization_params={"api_key": "sk-test", "ssl_verify": "/tmp/first-ca.pem"}, + client_type="openai", + ) + second_key = BaseOpenAILLM.get_openai_client_cache_key( + client_initialization_params={"api_key": "sk-test", "ssl_verify": "/tmp/second-ca.pem"}, + client_type="openai", + ) + + assert first_key != second_key + + +def test_get_sync_http_client_uses_per_call_ssl_verify(monkeypatch): + from litellm.llms.openai import common_utils + + monkeypatch.setattr(litellm, "client_session", None) + monkeypatch.setattr(litellm, "network_mock", False) + with ( + patch.object( # test-quality-ok: verify the per-call SSL setting reaches the resolver + common_utils, "get_ssl_configuration", return_value=False + ) as get_ssl_configuration, + patch.object( # test-quality-ok: capture the constructed HTTP client options + common_utils.httpx, "Client" + ) as http_client, + ): + result = BaseOpenAILLM._get_sync_http_client(ssl_verify="/tmp/custom-ca.pem") + + assert result is http_client.return_value + get_ssl_configuration.assert_called_once_with(ssl_verify="/tmp/custom-ca.pem") + http_client.assert_called_once_with(verify=False, follow_redirects=True) + + +@pytest.mark.asyncio +async def test_get_async_http_client_uses_per_call_ssl_verify(monkeypatch): + from litellm.llms.openai import common_utils + + monkeypatch.setattr(litellm, "aclient_session", None) + monkeypatch.setattr(litellm, "network_mock", False) + with ( + patch.object( # test-quality-ok: verify the per-call SSL setting reaches the resolver + common_utils, "get_ssl_configuration", return_value=False + ) as get_ssl_configuration, + patch.object( # test-quality-ok: capture async transport options + common_utils.AsyncHTTPHandler, "_create_async_transport", return_value=None + ) as transport, + patch.object( # test-quality-ok: capture the constructed HTTP client options + common_utils.httpx, "AsyncClient" + ) as http_client, + ): + result = BaseOpenAILLM._get_async_http_client(ssl_verify="/tmp/custom-ca.pem") + + assert result is http_client.return_value + get_ssl_configuration.assert_called_once_with(ssl_verify="/tmp/custom-ca.pem") + transport.assert_called_once_with(ssl_context=None, ssl_verify=False, shared_session=None) + http_client.assert_called_once_with(verify=False, transport=None, follow_redirects=True) + + +def test_openai_client_ssl_verify(monkeypatch): # test-quality-ok: verifies per-call SSL reaches the client boundary + from litellm.llms.openai.openai import OpenAIChatCompletion + + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", MagicMock()) + with ( + patch.object( # test-quality-ok: force the fresh-client path + BaseOpenAILLM, "get_cached_openai_client", return_value=None + ), + patch.object( # test-quality-ok: avoid mutating the shared client cache + BaseOpenAILLM, "set_cached_openai_client" + ), + patch.object( # test-quality-ok: observe the HTTP client boundary + OpenAIChatCompletion, "_get_sync_http_client" + ) as get_http_client, + patch( # test-quality-ok: avoid constructing a provider client + "litellm.llms.openai.openai.OpenAI" + ) as openai_client, + ): + OpenAIChatCompletion()._get_openai_client( + is_async=False, + api_key="sk-test", + api_base="https://example.test/v1", + max_retries=2, + ssl_verify="/tmp/custom-ca.pem", + ) + + get_http_client.assert_called_once_with(ssl_verify="/tmp/custom-ca.pem") + assert openai_client.call_count == 1 + + def test_evicting_a_client_built_on_the_callers_session_leaves_that_session_open(monkeypatch): """`litellm.aclient_session` belongs to the caller, who goes on using it.