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