This commit is contained in:
Praveena Ganesan 2026-09-13 18:26:25 +00:00 committed by GitHub
commit f98514bf23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 216 additions and 5 deletions

View file

@ -278,6 +278,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 +304,7 @@ class BaseOpenAILLM:
@staticmethod
def _get_async_http_client(
shared_session: Optional["ClientSession"] = None,
ssl_verify: bool | str | ssl.SSLContext | None = None,
) -> httpx.AsyncClient | None:
if litellm.aclient_session is not None:
return litellm.aclient_session
@ -313,7 +315,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)
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 +330,9 @@ class BaseOpenAILLM:
)
@staticmethod
def _get_sync_http_client() -> httpx.Client | None:
def _get_sync_http_client(
ssl_verify: bool | str | ssl.SSLContext | None = None,
) -> httpx.Client | None:
if litellm.client_session is not None:
return litellm.client_session
@ -338,7 +342,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,

View file

@ -1,3 +1,4 @@
import ssl
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: bool | str | ssl.SSLContext | 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,7 @@ 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,6 +778,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):
@ -786,6 +792,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
max_retries=max_retries,
organization=organization,
client=client,
ssl_verify=litellm_params.get("ssl_verify"),
)
## LOGGING
@ -928,6 +935,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
organization=organization,
client=client,
shared_session=shared_session,
ssl_verify=litellm_params.get("ssl_verify"),
)
## LOGGING
@ -1024,6 +1032,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
max_retries=None,
headers=None,
stream_options: dict | None = None,
ssl_verify: bool | str | ssl.SSLContext | None = None,
):
data["stream"] = True
data.update(self.get_stream_options(stream_options=stream_options, api_base=api_base))
@ -1037,6 +1046,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
max_retries=max_retries,
organization=organization,
client=client,
ssl_verify=ssl_verify,
)
## LOGGING
logging_obj.pre_call(
@ -1109,6 +1119,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(

View file

@ -352,6 +352,11 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = (
# the request away from the admin's pinned configuration.
"nvcf_function_id",
"use_ssl",
# TLS trust decision for the outbound provider connection. A caller-supplied
# value downgrades or disables certificate verification on a connection the
# admin pinned, and a string value reaches os.path.exists() as a local-file
# oracle. Deployment-level config only, same as ``use_ssl`` above.
"ssl_verify",
# Per-deployment opt-in that hands the whole call to the Rust core. It is a
# deployment decision, not a request one: the Rust path uses its own client
# rather than the one the deployment configured, and reports no post_call,

View file

@ -3747,6 +3747,7 @@ all_litellm_params = (
"prompt_version",
"prompt_environment",
"api_base",
"ssl_verify",
"force_timeout",
"logger_fn",
"verbose",

View file

@ -0,0 +1,120 @@
"""
Regression test for issue #38178.
``ssl_verify`` is a LiteLLM-level TLS setting, not a provider body param. It has to
configure the httpx client backing the OpenAI SDK client, and it must never be swept
into ``extra_body``: OpenAI-compatible endpoints reject unknown body fields with a 400.
"""
from pathlib import Path
from unittest.mock import MagicMock
import certifi
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import get_ssl_configuration
from litellm.llms.openai.openai import OpenAIChatCompletion
from litellm.types.utils import all_litellm_params
from litellm.utils import get_non_default_completion_params
@pytest.fixture
def ca_bundle(tmp_path: Path) -> str:
"""A real, loadable CA bundle at a path that is not certifi's default."""
bundle = tmp_path / "corporate-ca.crt"
bundle.write_bytes(Path(certifi.where()).read_bytes())
return str(bundle)
def test_ssl_verify_is_a_known_litellm_param():
assert "ssl_verify" in all_litellm_params
def test_ssl_verify_not_forwarded_as_provider_param(ca_bundle: str):
forwarded = get_non_default_completion_params({"ssl_verify": ca_bundle, "temperature": 0.5})
assert "ssl_verify" not in forwarded
def test_completion_does_not_leak_ssl_verify_into_provider_request_body(ca_bundle: str):
mock_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "hi"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
mock_raw_response = MagicMock()
mock_raw_response.headers = {}
mock_raw_response.parse.return_value = mock_response
mock_client = MagicMock()
mock_client.chat.completions.with_raw_response.create.return_value = mock_raw_response
litellm.completion(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "hi"}],
ssl_verify=ca_bundle,
api_key="sk-test",
client=mock_client,
)
create_kwargs = mock_client.chat.completions.with_raw_response.create.call_args.kwargs
assert "ssl_verify" not in create_kwargs
assert "ssl_verify" not in (create_kwargs.get("extra_body") or {})
def test_sync_openai_client_uses_ssl_verify_ca_bundle(ca_bundle: str):
handler = OpenAIChatCompletion()
client = handler._get_openai_client(
is_async=False,
api_key="sk-test",
api_base="https://private.example.com/v1",
max_retries=0,
ssl_verify=ca_bundle,
)
assert client is not None
pool = client._client._transport._pool
assert pool._ssl_context is get_ssl_configuration(ssl_verify=ca_bundle)
assert pool._ssl_context is not get_ssl_configuration()
@pytest.mark.asyncio
async def test_async_openai_client_uses_ssl_verify_ca_bundle(ca_bundle: str):
handler = OpenAIChatCompletion()
client = handler._get_openai_client(
is_async=True,
api_key="sk-test",
api_base="https://private.example.com/v1",
max_retries=0,
ssl_verify=ca_bundle,
)
assert client is not None
session = client._client._transport._client_factory()
try:
assert session.connector._ssl is get_ssl_configuration(ssl_verify=ca_bundle)
assert session.connector._ssl is not get_ssl_configuration()
finally:
await session.close()
def test_openai_client_cache_is_keyed_on_ssl_verify(ca_bundle: str):
handler = OpenAIChatCompletion()
shared_args = {
"is_async": False,
"api_key": "sk-test",
"api_base": "https://private.example.com/v1",
"max_retries": 0,
}
with_ca = handler._get_openai_client(**shared_args, ssl_verify=ca_bundle)
without_ca = handler._get_openai_client(**shared_args)
assert with_ca is not without_ca

View file

@ -2616,6 +2616,75 @@ class TestIsRequestBodySafeBlocksRivaUseSsl:
)
class TestIsRequestBodySafeBlocksSslVerify:
"""``ssl_verify`` configures TLS trust for the outbound provider connection.
A caller-supplied ``false`` disables certificate verification on a
connection the admin pinned, and a string value reaches
``os.path.exists()`` as a local-file oracle, so it is rejected as a
request-body param unless the admin opted in proxy-wide or
per-deployment, same as ``use_ssl`` above."""
def test_ssl_verify_false_in_request_body_is_rejected(self):
with pytest.raises(ValueError, match="ssl_verify"):
is_request_body_safe(
request_body={
"model": "openai/gpt-3.5-turbo",
"ssl_verify": False,
},
general_settings={},
llm_router=None,
model="openai/gpt-3.5-turbo",
)
def test_ssl_verify_path_in_request_body_is_rejected(self):
with pytest.raises(ValueError, match="ssl_verify"):
is_request_body_safe(
request_body={
"model": "openai/gpt-3.5-turbo",
"ssl_verify": "/etc/passwd",
},
general_settings={},
llm_router=None,
model="openai/gpt-3.5-turbo",
)
def test_admin_opt_in_proxy_wide_allows_ssl_verify(self):
assert (
is_request_body_safe(
request_body={
"model": "openai/gpt-3.5-turbo",
"ssl_verify": "/opt/app/certs/ca.crt",
},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="openai/gpt-3.5-turbo",
)
is True
)
def test_admin_opt_in_per_deployment_allows_ssl_verify(self, monkeypatch):
from litellm.proxy.auth import auth_utils
monkeypatch.setattr(
auth_utils,
"_allow_model_level_clientside_configurable_parameters",
lambda model, param, request_body_value, llm_router: param == "ssl_verify",
)
assert (
is_request_body_safe(
request_body={
"model": "openai/gpt-3.5-turbo",
"ssl_verify": "/opt/app/certs/ca.crt",
},
general_settings={},
llm_router=None,
model="openai/gpt-3.5-turbo",
)
is True
)
class TestIsRequestBodySafeBlocksBedrockTags:
"""``bedrock_tags`` lands as AWS resource tags on Bedrock batch jobs
created with the proxy's AWS identity, so a caller-supplied value can

View file

@ -28,6 +28,7 @@ from litellm.proxy.auth.auth_utils import is_request_body_safe # noqa: E402
"base_url",
"vertex_credentials",
"azure_ad_token",
"ssl_verify",
],
)
def test_banned_param_under_extra_body_is_rejected(banned_param):