From da4f6974ea0097497bb4017f04b4e2f39fc79071 Mon Sep 17 00:00:00 2001 From: Raphael Zanarelli Date: Wed, 7 Oct 2026 21:18:03 -0300 Subject: [PATCH] fix(openai): do not pass ssl_verify to extra_body and apply per-call TLS config to HTTP client Per-call ssl_verify was forwarded in extra_body to OpenAI-compatible endpoints and never reached the HTTP client. Strip it from the provider params and apply it when the OpenAI HTTP client is built, keeping the client cache key distinct per ssl_verify value. Keep it out of user-controlled request fields on the proxy. Signed-off-by: Raphael Zanarelli --- litellm/llms/openai/common_utils.py | 11 +- litellm/llms/openai/openai.py | 30 +++- litellm/main.py | 4 +- litellm/proxy/litellm_pre_call_utils.py | 1 + litellm/utils.py | 2 +- .../llms/openai/test_openai_ssl_verify.py | 134 ++++++++++++++++++ .../unit/proxy/test_litellm_pre_call_utils.py | 2 + 7 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 tests/unit/llms/openai/test_openai_ssl_verify.py diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index ffc5a5a71e0..1dc55781d7a 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -34,6 +34,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_ssl_configuration, http2_enabled, ) +from litellm.types.llms.custom_http import VerifyTypes def _get_client_init_params(cls: type) -> tuple[str, ...]: @@ -279,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) @@ -304,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 @@ -314,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, @@ -337,7 +340,9 @@ class BaseOpenAILLM: return cls._get_async_http_client(shared_session) @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 @@ -347,7 +352,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 a782bf35ac8..d3972bc7b5a 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Final, Literal, Optional, cast import httpx +from litellm.types.llms.custom_http import VerifyTypes + if TYPE_CHECKING: from aiohttp import ClientSession @@ -390,6 +392,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): client: OpenAI | AsyncOpenAI | None = None, shared_session: Optional["ClientSession"] = None, litellm_params: Mapping[str, object] | None = 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, litellm_params=litellm_params @@ -410,7 +413,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( @@ -432,7 +438,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( @@ -796,6 +804,9 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): headers=headers or {}, ) if stream is True and fake_stream is False: + stream_ssl_verify: VerifyTypes | None = ( + cast(VerifyTypes | None, litellm_params.get("ssl_verify", None)) if litellm_params else None + ) return self.streaming( logging_obj=logging_obj, headers=headers, @@ -810,10 +821,14 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, stream_options=stream_options, litellm_params=litellm_params, + ssl_verify=stream_ssl_verify, ) else: if not isinstance(max_retries, int): raise OpenAIError(status_code=422, message="max retries must be an int") + ssl_verify: VerifyTypes | None = ( + cast(VerifyTypes | None, 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, @@ -824,6 +839,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, litellm_params=litellm_params, + ssl_verify=ssl_verify, ) ## LOGGING @@ -956,6 +972,9 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): ) for _ in range(2): # if call fails due to alternating messages, retry with reformatted message try: + ssl_verify: VerifyTypes | None = ( + cast(VerifyTypes | None, 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, @@ -967,6 +986,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): client=client, shared_session=shared_session, litellm_params=litellm_params, + ssl_verify=ssl_verify, ) ## LOGGING @@ -1064,6 +1084,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): headers=None, stream_options: dict | None = None, litellm_params: Mapping[str, object] | None = None, + ssl_verify: VerifyTypes | None = None, ): data["stream"] = True data.update(self.get_stream_options(stream_options=stream_options, api_base=api_base)) @@ -1078,6 +1099,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, litellm_params=litellm_params, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( @@ -1140,6 +1162,9 @@ 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: VerifyTypes | None = ( + cast(VerifyTypes | None, 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, @@ -1151,6 +1176,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): client=client, shared_session=shared_session, litellm_params=litellm_params, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( diff --git a/litellm/main.py b/litellm/main.py index 37b290f51ba..f227e43d945 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5372,7 +5372,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) @@ -7356,7 +7356,7 @@ 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)}, ) elif custom_llm_provider == "perplexity": response = base_llm_http_handler.embedding( diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 452c653eef0..ebb473aea89 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -259,6 +259,7 @@ LITELLM_TRACE_CONTROL_METADATA_FIELDS: Final = frozenset( ) _UNTRUSTED_ROOT_CONTROL_FIELDS: Final = ( + "ssl_verify", "weights", "_router_weights", "proxy_server_request", diff --git a/litellm/utils.py b/litellm/utils.py index 72ce14912a9..96e31facacb 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5087,7 +5087,7 @@ def add_provider_specific_params_to_optional_params( if _should_drop_param(k="extra_body", additional_drop_params=additional_drop_params) is False: 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 not in openai_params and k != "ssl_verify" and passed_params[k] is not None: extra_body[k] = passed_params[k] if not isinstance(optional_params.get("extra_body"), dict): optional_params["extra_body"] = {} diff --git a/tests/unit/llms/openai/test_openai_ssl_verify.py b/tests/unit/llms/openai/test_openai_ssl_verify.py new file mode 100644 index 00000000000..0f8742f6e49 --- /dev/null +++ b/tests/unit/llms/openai/test_openai_ssl_verify.py @@ -0,0 +1,134 @@ +import json +import ssl +from typing import Final +from unittest.mock import patch + +import certifi +import httpx +import pytest + +import litellm +from litellm.llms.openai.common_utils import BaseOpenAILLM +from litellm.utils import 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 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verify", [False, certifi.where(), None, True], ids=["disabled", "custom-ca", "default", "enabled"] +) +async def test_ssl_verify_http_client(verify: bool | str | None): + def respond(request: httpx.Request) -> httpx.Response: + payload: Final = json.loads(request.content) + assert "ssl_verify" not in payload + return httpx.Response( + 200, + json={ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1, + "model": "gpt-3.5-turbo", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "hello"}, "finish_reason": "stop"} + ], + }, + ) + + class TLSClient(httpx.AsyncClient): + def __init__(self, **kwargs: object): + verify_config: Final = kwargs.get("verify", True) + + def verified_response(request: httpx.Request) -> httpx.Response: + if verify_setting is False: + assert verify_config is False + else: + assert isinstance(verify_config, ssl.SSLContext) + assert verify_config.verify_mode == ssl.CERT_REQUIRED + assert verify_config.check_hostname + return respond(request) + + client_kwargs: Final = {key: value for key, value in kwargs.items() if key not in {"transport", "mounts"}} + super().__init__(**client_kwargs, transport=httpx.MockTransport(verified_response)) + + verify_setting: Final = verify + router: Final = litellm.Router( + num_retries=0, + model_list=[ + { + "model_name": "tls-test", + "litellm_params": { + "model": "openai/gpt-3.5-turbo", + "api_key": f"sk-test-{verify}", + "ssl_verify": verify, + }, + } + ], + ) + with patch("httpx.AsyncClient", TLSClient): + response: Final = await router.acompletion( + model="tls-test", + messages=[{"role": "user", "content": "hi"}], + ) + assert response.choices[0].message.content == "hello" diff --git a/tests/unit/proxy/test_litellm_pre_call_utils.py b/tests/unit/proxy/test_litellm_pre_call_utils.py index d4624719565..24a062bd4cf 100644 --- a/tests/unit/proxy/test_litellm_pre_call_utils.py +++ b/tests/unit/proxy/test_litellm_pre_call_utils.py @@ -1088,6 +1088,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields(): "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "hello"}], "mock_response": "free response", + "ssl_verify": False, "mock_tool_calls": [{"id": "call_1"}], "disable_global_guardrails": True, "enable_prompt_caching": True, @@ -1108,6 +1109,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields(): version="test-version", ) + assert "ssl_verify" not in updated assert "mock_response" not in updated assert "mock_tool_calls" not in updated assert "disable_global_guardrails" not in updated