From dba1f2d3f2c473e029364aadad0d0ebff878ad37 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 2 Jun 2026 18:51:25 +0530 Subject: [PATCH] fix(azure_ai): strip tool-level extra fields on 400 and retry (#29479) * fix(azure_ai): strip tool-level extra fields (e.g. copilot_mcp_server_name) before retrying * fix(azure_ai): move re import to top-level; fix regex to handle hyphenated field names --- litellm/llms/azure_ai/chat/transformation.py | 46 ++++++++++---- .../chat/test_azure_ai_transformation.py | 62 +++++++++++++++++++ 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/litellm/llms/azure_ai/chat/transformation.py b/litellm/llms/azure_ai/chat/transformation.py index 529ec71c53..008a8a766e 100644 --- a/litellm/llms/azure_ai/chat/transformation.py +++ b/litellm/llms/azure_ai/chat/transformation.py @@ -1,4 +1,5 @@ import enum +import re from typing import Any, List, Optional, Tuple, cast from urllib.parse import urlparse @@ -275,21 +276,25 @@ class AzureAIStudioConfig(OpenAIConfig): should_drop_params = litellm_params.get("drop_params") or litellm.drop_params error_text = e.response.text - if should_drop_params and "Extra inputs are not permitted" in error_text: + if "Extra inputs are not permitted" in error_text: + if should_drop_params or self._error_has_tool_level_extra_fields( + error_text + ): + return True + if "unknown field: parameter index is not a valid field" in error_text: return True - elif ( - "unknown field: parameter index is not a valid field" in error_text - ): # remove index from tool calls - return True - elif ( + if ( AzureFoundryErrorStrings.SET_EXTRA_PARAMETERS_TO_PASS_THROUGH.value in error_text - ): # remove extra-parameters from tool calls + ): return True return super().should_retry_llm_api_inside_llm_translation_on_http_error( e=e, litellm_params=litellm_params ) + def _error_has_tool_level_extra_fields(self, error_text: str) -> bool: + return bool(re.search(r"tools\[\d+\]\.", error_text)) + @property def max_retry_on_unprocessable_entity_error(self) -> int: return 2 @@ -297,9 +302,10 @@ class AzureAIStudioConfig(OpenAIConfig): def transform_request_on_unprocessable_entity_error( self, e: httpx.HTTPStatusError, request_data: dict ) -> dict: + error_text = e.response.text _messages = cast(Optional[List[AllMessageValues]], request_data.get("messages")) if ( - "unknown field: parameter index is not a valid field" in e.response.text + "unknown field: parameter index is not a valid field" in error_text and _messages is not None ): litellm.remove_index_from_tool_calls( @@ -307,14 +313,31 @@ class AzureAIStudioConfig(OpenAIConfig): ) elif ( AzureFoundryErrorStrings.SET_EXTRA_PARAMETERS_TO_PASS_THROUGH.value - in e.response.text + in error_text ): request_data = self._drop_extra_params_from_request_data( - request_data, e.response.text + request_data, error_text ) + if ( + "Extra inputs are not permitted" in error_text + and self._error_has_tool_level_extra_fields(error_text) + ): + request_data = self._drop_tool_level_extra_fields(request_data, error_text) data = drop_params_from_unprocessable_entity_error(e=e, data=request_data) return data + def _drop_tool_level_extra_fields( + self, request_data: dict, error_text: str + ) -> dict: + fields_to_drop = set(re.findall(r"tools\[\d+\]\.([\w-]+)", error_text)) + tools = request_data.get("tools") + if fields_to_drop and isinstance(tools, list): + for tool in tools: + if isinstance(tool, dict): + for field in fields_to_drop: + tool.pop(field, None) + return request_data + def _drop_extra_params_from_request_data( self, request_data: dict, error_text: str ) -> dict: @@ -332,9 +355,6 @@ class AzureAIStudioConfig(OpenAIConfig): Error text looks like this" "Extra parameters ['stream_options', 'extra-parameters'] are not allowed when extra-parameters is not set or set to be 'error'. """ - import re - - # Extract parameters within square brackets match = re.search(r"\[(.*?)\]", error_text) if not match: return [] diff --git a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py b/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py index 3ba8395b02..2e75039139 100644 --- a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py +++ b/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py @@ -200,3 +200,65 @@ def test_azure_model_router_response_shows_actual_model(): f"Expected model to be 'azure_ai/gpt-5-nano-2025-08-07' (actual model used), " f"but got '{result.model}'" ) + + +def test_drop_tool_level_extra_fields_strips_copilot_mcp_server_name(): + """ + Regression test: Azure AI returns 400 when tools contain copilot_mcp_server_name. + LiteLLM should strip the field and retry automatically. + """ + import httpx + + config = AzureAIStudioConfig() + + error_text = json.dumps( + { + "error": { + "message": "2 request validation errors: Extra inputs are not permitted, field: 'tools[0].copilot_mcp_server_name', value: 'github-mcp-server'; Extra inputs are not permitted, field: 'tools[1].copilot_mcp_server_name', value: 'ide'" + } + } + ) + mock_response = MagicMock(spec=httpx.Response) + mock_response.text = error_text + mock_response.json.return_value = json.loads(error_text) + mock_response.status_code = 400 + e = httpx.HTTPStatusError( + message="400", request=MagicMock(), response=mock_response + ) + + assert config._error_has_tool_level_extra_fields(error_text) is True + assert ( + config.should_retry_llm_api_inside_llm_translation_on_http_error(e, {}) is True + ) + + request_data = { + "model": "FW-Kimi-K2.6", + "messages": [{"role": "user", "content": "Say hi."}], + "tools": [ + { + "type": "function", + "copilot_mcp_server_name": "github-mcp-server", + "function": { + "name": "github_search_code", + "description": "Search code", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "copilot_mcp_server_name": "ide", + "function": { + "name": "read_file", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + }, + }, + ], + } + + result = config.transform_request_on_unprocessable_entity_error(e, request_data) + + for tool in result["tools"]: + assert "copilot_mcp_server_name" not in tool + assert result["tools"][0]["type"] == "function" + assert result["tools"][1]["function"]["name"] == "read_file"