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 <zanarelli.dev@gmail.com>
This commit is contained in:
Raphael Zanarelli 2026-10-07 21:18:03 -03:00
parent 0734e35024
commit da4f6974ea
7 changed files with 176 additions and 8 deletions

View file

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

View file

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

View file

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

View file

@ -259,6 +259,7 @@ LITELLM_TRACE_CONTROL_METADATA_FIELDS: Final = frozenset(
)
_UNTRUSTED_ROOT_CONTROL_FIELDS: Final = (
"ssl_verify",
"weights",
"_router_weights",
"proxy_server_request",

View file

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

View file

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

View file

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