Merge pull request #15253 from BerriAI/litellm_dev_10_06_2025_p2

fix(azure/responses): remove invalid status param from azure call + MCP - support setting CA_BUNDLE_PATH
This commit is contained in:
Krish Dholakia 2025-10-06 20:01:39 -07:00 committed by GitHub
commit 6b4415684e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 345 additions and 84 deletions

View file

View file

@ -5,8 +5,9 @@ LiteLLM Proxy uses this MCP Client to connnect to other MCP servers.
import asyncio
import base64
from datetime import timedelta
from typing import Dict, List, Optional, Union
from typing import Callable, Dict, List, Optional, Union
import httpx
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
@ -17,6 +18,8 @@ from mcp.types import TextContent
from mcp.types import Tool as MCPTool
from litellm._logging import verbose_logger
from litellm.llms.custom_httpx.http_handler import get_ssl_configuration
from litellm.types.llms.custom_http import VerifyTypes
from litellm.types.mcp import (
MCPAuth,
MCPAuthType,
@ -48,6 +51,7 @@ class MCPClient:
timeout: float = 60.0,
stdio_config: Optional[MCPStdioConfig] = None,
extra_headers: Optional[Dict[str, str]] = None,
ssl_verify: Optional[VerifyTypes] = None,
):
self.server_url: str = server_url
self.transport_type: MCPTransport = transport_type
@ -62,6 +66,7 @@ class MCPClient:
self._task: Optional[asyncio.Task] = None
self.stdio_config: Optional[MCPStdioConfig] = stdio_config
self.extra_headers: Optional[Dict[str, str]] = extra_headers
self.ssl_verify: Optional[VerifyTypes] = ssl_verify
# handle the basic auth value if provided
if auth_value:
self.update_auth_value(auth_value)
@ -104,10 +109,12 @@ class MCPClient:
await self._session.initialize()
elif self.transport_type == MCPTransport.sse:
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
self._transport_ctx = sse_client(
url=self.server_url,
timeout=self.timeout,
headers=headers,
httpx_client_factory=httpx_client_factory,
)
self._transport = await self._transport_ctx.__aenter__()
self._session_ctx = ClientSession(
@ -117,6 +124,7 @@ class MCPClient:
await self._session.initialize()
else: # http
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
verbose_logger.debug(
"litellm headers for streamablehttp_client: %s", headers
)
@ -124,6 +132,7 @@ class MCPClient:
url=self.server_url,
timeout=timedelta(seconds=self.timeout),
headers=headers,
httpx_client_factory=httpx_client_factory,
)
self._transport = await self._transport_ctx.__aenter__()
self._session_ctx = ClientSession(
@ -215,6 +224,41 @@ class MCPClient:
return headers
def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]:
"""
Create a custom httpx client factory that uses LiteLLM's SSL configuration.
This factory follows the same CA bundle path logic as http_handler.py:
1. Check ssl_verify parameter (can be SSLContext, bool, or path to CA bundle)
2. Check SSL_VERIFY environment variable
3. Check SSL_CERT_FILE environment variable
4. Fall back to certifi CA bundle
"""
def factory(
*,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[httpx.Timeout] = None,
auth: Optional[httpx.Auth] = None,
) -> httpx.AsyncClient:
"""Create an httpx.AsyncClient with LiteLLM's SSL configuration."""
# Get unified SSL configuration using the same logic as http_handler.py
ssl_config = get_ssl_configuration(self.ssl_verify)
verbose_logger.debug(
f"MCP client using SSL configuration: {type(ssl_config).__name__}"
)
return httpx.AsyncClient(
headers=headers,
timeout=timeout,
auth=auth,
verify=ssl_config,
follow_redirects=True,
)
return factory
async def list_tools(self) -> List[MCPTool]:
"""List available tools from the server."""
if not self._session:

View file

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
import httpx
from openai.types.responses import ResponseReasoningItem
from litellm._logging import verbose_logger
from litellm.llms.azure.common_utils import BaseAzureLLM
@ -38,6 +39,50 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
model = model.replace("o_series/", "")
return model
def _handle_reasoning_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
"""
Handle reasoning items specifically to filter out status=None using OpenAI's model.
Issue: https://github.com/BerriAI/litellm/issues/13484
OpenAI API does not accept ReasoningItem(status=None), so we need to:
1. Check if the item is a reasoning type
2. Create a ResponseReasoningItem object with the item data
3. Convert it back to dict with exclude_none=True to filter None values
"""
if item.get("type") == "reasoning":
try:
# Ensure required fields are present for ResponseReasoningItem
item_data = dict(item)
if "id" not in item_data:
item_data["id"] = f"reasoning_{hash(str(item_data))}"
if "summary" not in item_data:
item_data["summary"] = (
item_data.get("reasoning_content", "")[:100] + "..."
if len(item_data.get("reasoning_content", "")) > 100
else item_data.get("reasoning_content", "")
)
# Create ResponseReasoningItem object from the item data
reasoning_item = ResponseReasoningItem(**item_data)
# Convert back to dict with exclude_none=True to exclude None fields
dict_reasoning_item = reasoning_item.model_dump(exclude_none=True)
dict_reasoning_item.pop("status", None)
return dict_reasoning_item
except Exception as e:
verbose_logger.debug(
f"Failed to create ResponseReasoningItem, falling back to manual filtering: {e}"
)
# Fallback: manually filter out known None fields
filtered_item = {
k: v
for k, v in item.items()
if v is not None
or k not in {"status", "content", "encrypted_content"}
}
return filtered_item
return item
def transform_responses_api_request(
self,
model: str,
@ -48,12 +93,13 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
) -> Dict:
"""No transform applied since inputs are in OpenAI spec already"""
stripped_model_name = self.get_stripped_model_name(model)
return dict(
ResponsesAPIRequestParams(
model=stripped_model_name,
input=input,
**response_api_optional_request_params,
)
return super().transform_responses_api_request(
model=stripped_model_name,
input=input,
response_api_optional_request_params=response_api_optional_request_params,
litellm_params=litellm_params,
headers=headers,
)
def get_complete_url(
@ -217,15 +263,15 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
at the correct location (before any query parameters).
"""
from urllib.parse import urlparse, urlunparse
# Parse the URL to separate its components
parsed_url = urlparse(api_base)
# Insert the response_id and /cancel at the end of the path component
# Remove trailing slash if present to avoid double slashes
path = parsed_url.path.rstrip("/")
new_path = f"{path}/{response_id}/cancel"
# Reconstruct the URL with all original components but with the modified path
cancel_url = urlunparse(
(

View file

@ -1,12 +1,4 @@
from typing import (
TYPE_CHECKING,
Any,
Dict,
Optional,
Union,
cast,
get_type_hints,
)
from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast, get_type_hints
import httpx
from openai.types.responses import ResponseReasoningItem
@ -127,7 +119,6 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
2. Create a ResponseReasoningItem object with the item data
3. Convert it back to dict with exclude_none=True to filter None values
"""
verbose_logger.debug(f"Handling reasoning item: {item}")
if item.get("type") == "reasoning":
try:
# Ensure required fields are present for ResponseReasoningItem

File diff suppressed because one or more lines are too long

View file

@ -1,33 +1,6 @@
model_list:
- model_name: openai/gpt-4o
- model_name: gpt-5-mini
litellm_params:
model: openai/gpt-4o-mini
api_base: "https://webhook.site/2f385e05-00aa-402b-86d1-efc9261471a5"
api_key: dummy
- model_name: "byok-wildcard/*"
litellm_params:
model: openai/*
- model_name: xai-grok-3
litellm_params:
model: xai/grok-3
- model_name: hosted_vllm/whisper-v3
litellm_params:
model: hosted_vllm/whisper-v3
api_base: "https://webhook.site/2f385e05-00aa-402b-86d1-efc9261471a5"
api_key: dummy
# mcp_servers:
# github_mcp:
# url: "https://api.githubcopilot.com/mcp"
# auth_type: oauth2
# authorization_url: https://github.com/login/oauth/authorize
# token_url: https://github.com/login/oauth/access_token
# client_id: os.environ/GITHUB_OAUTH_CLIENT_ID
# client_secret: os.environ/GITHUB_OAUTH_CLIENT_SECRET
# scopes: ["public_repo", "user:email"]
# allowed_tools: ["list_tools"]
# # disallowed_tools: ["repo_delete"]
litellm_settings:
callbacks: ["prometheus"]
custom_prometheus_metadata_labels: ["metadata.initiative", "metadata.business-unit"]
model: azure/gpt-5-mini-2
api_key: os.environ/AZURE_API_KEY_ALT
api_base: os.environ/AZURE_API_BASE_ALT

View file

@ -19,6 +19,7 @@ from litellm.types.llms.openai import (
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from base_responses_api import BaseResponsesAPITest
class TestAzureResponsesAPITest(BaseResponsesAPITest):
def get_base_completion_call_args(self):
return {
@ -43,4 +44,55 @@ async def test_azure_responses_api_preview_api_version():
api_base=os.getenv("AZURE_RESPONSES_OPENAI_ENDPOINT"),
api_key=os.getenv("AZURE_RESPONSES_OPENAI_API_KEY"),
input="Hello, can you tell me a short joke?",
)
)
@pytest.mark.asyncio
async def test_azure_responses_api_status_error():
"""
Ensure new azure preview api version is working
"""
litellm._turn_on_debug()
request_data = {
"model": "gpt-5-mini",
"input": [
{"content": "tell me an interesting fact", "role": "user"},
{
"id": "rs_0ab687487834d9df0068e462a1b2d88197aabbc832c9ba5316",
"summary": [],
"type": "reasoning",
"content": None,
"encrypted_content": None,
"status": "completed",
},
{
"id": "msg_0ab687487834d9df0068e462a1df188197b74b1eef05102c18",
"content": [
{
"annotations": [],
"text": "Octopuses have three hearts: two pump blood to the gills, while the third pumps it to the rest of the body. Even more unusual, their blood is blue because it uses the copper-containing protein hemocyanin to carry oxygen, which is more efficient than hemoglobin in cold, low-oxygen environments.",
"type": "output_text",
"logprobs": [],
}
],
"role": "assistant",
"status": "completed",
"type": "message",
},
{"role": "user", "content": "tell me another"},
],
"include": [],
"instructions": "You are a helpful assistant.",
"reasoning": {"effort": "minimal"},
"stream": False,
"tools": [],
}
response = await litellm.aresponses(
model="azure/gpt-5-mini-2",
truncation="auto",
api_version="preview",
api_base=os.getenv("AZURE_GPT5_MINI_API_BASE"),
api_key=os.getenv("AZURE_GPT5_MINI_API_KEY"),
input=request_data["input"],
)

View file

@ -1,10 +1,13 @@
import os
import ssl
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
# Add the parent directory to the path so we can import litellm
sys.path.insert(0, '../../../')
sys.path.insert(0, "../../../")
from litellm.experimental_mcp_client.client import MCPClient
from litellm.types.mcp import MCPStdioConfig, MCPTransport
@ -16,57 +19,54 @@ class TestMCPClient:
def test_mcp_client_stdio_init(self):
"""Test MCPClient initialization with stdio config"""
stdio_config = MCPStdioConfig(
command="python",
args=["-m", "my_mcp_server"],
env={"DEBUG": "1"}
command="python", args=["-m", "my_mcp_server"], env={"DEBUG": "1"}
)
client = MCPClient(
transport_type=MCPTransport.stdio,
stdio_config=stdio_config
)
client = MCPClient(transport_type=MCPTransport.stdio, stdio_config=stdio_config)
assert client.transport_type == MCPTransport.stdio
assert client.stdio_config == stdio_config
assert client.stdio_config["command"] == "python"
assert client.stdio_config["args"] == ["-m", "my_mcp_server"]
assert client.stdio_config is not None
assert client.stdio_config.get("command") == "python"
assert client.stdio_config.get("args") == ["-m", "my_mcp_server"]
@pytest.mark.asyncio
async def test_mcp_client_stdio_connect_error(self):
"""Test MCP client stdio connection error handling"""
# Test missing stdio_config
client = MCPClient(transport_type=MCPTransport.stdio)
with pytest.raises(ValueError, match="stdio_config is required for stdio transport"):
with pytest.raises(
ValueError, match="stdio_config is required for stdio transport"
):
await client.connect()
@pytest.mark.asyncio
@patch('litellm.experimental_mcp_client.client.stdio_client')
@patch('litellm.experimental_mcp_client.client.ClientSession')
async def test_mcp_client_stdio_connect_success(self, mock_session, mock_stdio_client):
@patch("litellm.experimental_mcp_client.client.stdio_client")
@patch("litellm.experimental_mcp_client.client.ClientSession")
async def test_mcp_client_stdio_connect_success(
self, mock_session, mock_stdio_client
):
"""Test successful stdio connection"""
# Setup mocks
mock_transport = (MagicMock(), MagicMock())
mock_stdio_client.return_value.__aenter__ = AsyncMock(return_value=mock_transport)
mock_stdio_client.return_value.__aenter__ = AsyncMock(
return_value=mock_transport
)
mock_session_instance = MagicMock()
mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance)
mock_session_instance.initialize = AsyncMock()
mock_session.return_value = mock_session_instance
stdio_config = MCPStdioConfig(
command="python",
args=["-m", "my_mcp_server"],
env={"DEBUG": "1"}
command="python", args=["-m", "my_mcp_server"], env={"DEBUG": "1"}
)
client = MCPClient(
transport_type=MCPTransport.stdio,
stdio_config=stdio_config
)
client = MCPClient(transport_type=MCPTransport.stdio, stdio_config=stdio_config)
await client.connect()
# Verify stdio_client was called with correct parameters
mock_stdio_client.assert_called_once()
call_args = mock_stdio_client.call_args[0][0]
@ -74,6 +74,162 @@ class TestMCPClient:
assert call_args.args == ["-m", "my_mcp_server"]
assert call_args.env == {"DEBUG": "1"}
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
@patch.dict(
os.environ,
{
"SSL_CERT_FILE": "/path/to/custom/ca-bundle.pem",
"SSL_CERTIFICATE": "/path/to/client-cert.pem",
},
)
async def test_mcp_client_ssl_configuration_from_env(
self, mock_streamablehttp_client
):
"""Test that MCP client uses SSL configuration from environment variables"""
# Setup mocks
mock_transport = (MagicMock(), MagicMock())
mock_streamablehttp_client.return_value.__aenter__ = AsyncMock(
return_value=mock_transport
)
# Mock the session
with patch(
"litellm.experimental_mcp_client.client.ClientSession"
) as mock_session:
mock_session_instance = MagicMock()
mock_session_instance.__aenter__ = AsyncMock(
return_value=mock_session_instance
)
mock_session_instance.initialize = AsyncMock()
mock_session.return_value = mock_session_instance
client = MCPClient(
server_url="https://mcp-server.example.com",
transport_type=MCPTransport.http,
)
await client.connect()
# Verify streamablehttp_client was called
mock_streamablehttp_client.assert_called_once()
call_kwargs = mock_streamablehttp_client.call_args[1]
# Verify httpx_client_factory was passed
assert "httpx_client_factory" in call_kwargs
httpx_factory = call_kwargs["httpx_client_factory"]
# Test the factory creates a client with proper SSL config
# When SSL_CERT_FILE is set, the factory should use get_ssl_configuration
test_client = httpx_factory(headers={"test": "header"})
# Verify the client was created successfully with SSL configuration
assert test_client is not None
assert isinstance(test_client, httpx.AsyncClient)
# Verify it has the expected properties
assert test_client.headers is not None
# Clean up
await test_client.aclose()
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.sse_client")
async def test_mcp_client_ssl_verify_parameter(self, mock_sse_client):
"""Test that MCP client uses ssl_verify parameter when provided"""
# Setup mocks
mock_transport = (MagicMock(), MagicMock())
mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=mock_transport)
# Mock the session
with patch(
"litellm.experimental_mcp_client.client.ClientSession"
) as mock_session:
mock_session_instance = MagicMock()
mock_session_instance.__aenter__ = AsyncMock(
return_value=mock_session_instance
)
mock_session_instance.initialize = AsyncMock()
mock_session.return_value = mock_session_instance
# Test with ssl_verify=False
client = MCPClient(
server_url="https://mcp-server.example.com",
transport_type=MCPTransport.sse,
ssl_verify=False,
)
await client.connect()
# Verify sse_client was called
mock_sse_client.assert_called_once()
call_kwargs = mock_sse_client.call_args[1]
# Verify httpx_client_factory was passed
assert "httpx_client_factory" in call_kwargs
httpx_factory = call_kwargs["httpx_client_factory"]
# Test the factory creates a client with SSL verification disabled
# When ssl_verify=False, the factory should disable SSL verification
test_client = httpx_factory(headers={"test": "header"})
# Verify the client was created successfully
assert test_client is not None
assert isinstance(test_client, httpx.AsyncClient)
# Verify it has the expected properties
assert test_client.headers is not None
# Clean up
await test_client.aclose()
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
async def test_mcp_client_ssl_verify_custom_path(self, mock_streamablehttp_client):
"""Test that MCP client uses custom CA bundle path from ssl_verify parameter"""
# Setup mocks
mock_transport = (MagicMock(), MagicMock())
mock_streamablehttp_client.return_value.__aenter__ = AsyncMock(
return_value=mock_transport
)
# Mock the session
with patch(
"litellm.experimental_mcp_client.client.ClientSession"
) as mock_session:
mock_session_instance = MagicMock()
mock_session_instance.__aenter__ = AsyncMock(
return_value=mock_session_instance
)
mock_session_instance.initialize = AsyncMock()
mock_session.return_value = mock_session_instance
# Test with custom CA bundle path
custom_ca_path = "/custom/path/to/ca-bundle.pem"
client = MCPClient(
server_url="https://mcp-server.example.com",
transport_type=MCPTransport.http,
ssl_verify=custom_ca_path,
)
await client.connect()
# Verify streamablehttp_client was called
mock_streamablehttp_client.assert_called_once()
call_kwargs = mock_streamablehttp_client.call_args[1]
# Verify httpx_client_factory was passed
assert "httpx_client_factory" in call_kwargs
httpx_factory = call_kwargs["httpx_client_factory"]
# Test the factory creates a client with custom CA bundle path
# When ssl_verify is a path, the factory should use that path for SSL verification
test_client = httpx_factory(headers={"test": "header"})
# Verify the client was created successfully
assert test_client is not None
assert isinstance(test_client, httpx.AsyncClient)
# Verify it has the expected properties
assert test_client.headers is not None
# Clean up
await test_client.aclose()
if __name__ == "__main__":
pytest.main([__file__])
pytest.main([__file__])