mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
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:
parent
0734e35024
commit
da4f6974ea
7 changed files with 176 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ LITELLM_TRACE_CONTROL_METADATA_FIELDS: Final = frozenset(
|
|||
)
|
||||
|
||||
_UNTRUSTED_ROOT_CONTROL_FIELDS: Final = (
|
||||
"ssl_verify",
|
||||
"weights",
|
||||
"_router_weights",
|
||||
"proxy_server_request",
|
||||
|
|
|
|||
|
|
@ -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"] = {}
|
||||
|
|
|
|||
134
tests/unit/llms/openai/test_openai_ssl_verify.py
Normal file
134
tests/unit/llms/openai/test_openai_ssl_verify.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue