mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
fix(openai): apply ssl_verify to the TLS client instead of leaking it into extra_body
ssl_verify was not listed in all_litellm_params, so the OpenAI param builder swept it into the request body's extra_body and OpenAI-compatible endpoints rejected the request. The OpenAI SDK path also built its httpx client from the global SSL config only, so a per-request CA bundle never reached TLS. Register ssl_verify as a LiteLLM-level param and thread it through _get_openai_client into the sync and async httpx clients, keyed into the client cache so deployments with different CA bundles don't share a client. Fixes #38178
This commit is contained in:
parent
c2c2a623c0
commit
60e6545c75
4 changed files with 141 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -3747,6 +3747,7 @@ all_litellm_params = (
|
|||
"prompt_version",
|
||||
"prompt_environment",
|
||||
"api_base",
|
||||
"ssl_verify",
|
||||
"force_timeout",
|
||||
"logger_fn",
|
||||
"verbose",
|
||||
|
|
|
|||
120
tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py
Normal file
120
tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue