diff --git a/MCP_SSL_CHANGES_SUMMARY.md b/MCP_SSL_CHANGES_SUMMARY.md
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py
index 081384b40d9..b10ddc9e812 100644
--- a/litellm/experimental_mcp_client/client.py
+++ b/litellm/experimental_mcp_client/client.py
@@ -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:
diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py
index 0050bd163d1..9cd5de46be9 100644
--- a/litellm/llms/azure/responses/transformation.py
+++ b/litellm/llms/azure/responses/transformation.py
@@ -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(
(
diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py
index 25078267571..3d0ec243e5c 100644
--- a/litellm/llms/openai/responses/transformation.py
+++ b/litellm/llms/openai/responses/transformation.py
@@ -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
diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html
similarity index 100%
rename from litellm/proxy/_experimental/out/model_hub_table.html
rename to litellm/proxy/_experimental/out/model_hub_table/index.html
diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html
deleted file mode 100644
index 6171839cc6d..00000000000
--- a/litellm/proxy/_experimental/out/onboarding.html
+++ /dev/null
@@ -1 +0,0 @@
-
LiteLLM Dashboard
\ No newline at end of file
diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml
index b7b30d36f99..52a6fc16ff1 100644
--- a/litellm/proxy/_new_secret_config.yaml
+++ b/litellm/proxy/_new_secret_config.yaml
@@ -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"]
\ No newline at end of file
+ model: azure/gpt-5-mini-2
+ api_key: os.environ/AZURE_API_KEY_ALT
+ api_base: os.environ/AZURE_API_BASE_ALT
diff --git a/tests/llm_responses_api_testing/test_azure_responses_api.py b/tests/llm_responses_api_testing/test_azure_responses_api.py
index 692f6774ac4..6305137ff45 100644
--- a/tests/llm_responses_api_testing/test_azure_responses_api.py
+++ b/tests/llm_responses_api_testing/test_azure_responses_api.py
@@ -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?",
- )
\ No newline at end of file
+ )
+
+
+@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"],
+ )
diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py
index cf1af288d56..9ecc10cafed 100644
--- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py
+++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py
@@ -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__])
\ No newline at end of file
+ pytest.main([__file__])