fix(openai): do not pass ssl_verify to extra_body and apply per-call TLS config to HTTP client

This commit is contained in:
Raphael Zanarelli 2026-08-31 21:41:18 -03:00 committed by zanarelli
parent 5a821b593c
commit 9153bc6236
6 changed files with 151 additions and 7 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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(

View file

@ -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"] = {}

View file

@ -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

View file

@ -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