fix(azure response api): flatten tools for responses api to support nested definitions (#19526)

The Azure Responses API uses a different schema (flattened) for tools compared to the standard OpenAI/Azure Chat Completions API (nested). This caused a `BadRequestError` when users passed standard tool definitions.

Changes:
- Implemented tool flattening logic in `AzureOpenAIResponsesAPIConfig.transform_responses_api_request`.
- Added comprehensive unit tests in test_azure_transformation.py to verify nested-to-flat transformation, pass-through of flat tools, and immutability.
- Ensures cross-provider compatibility for tool definitions.

Fixes #19523
This commit is contained in:
Yogeshwaran Ravichandran 2026-01-22 10:38:28 +05:30 • committed by GitHub
parent c8669cf8fa
commit ab274ac3c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 180 additions and 17 deletions

View file

@ -1,4 +1,5 @@
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
from copy import deepcopy
import httpx
from openai.types.responses import ResponseReasoningItem
@ -43,7 +44,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""
Handle reasoning items to filter out the status field.
Issue: https://github.com/BerriAI/litellm/issues/13484
Azure OpenAI API does not accept 'status' field in reasoning input items.
"""
if item.get("type") == "reasoning":
@ -78,7 +79,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
}
return filtered_item
return item
def _validate_input_param(
self, input: Union[str, ResponseInputParam]
) -> Union[str, ResponseInputParam]:
@ -90,7 +91,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
# First call parent's validation
validated_input = super()._validate_input_param(input)
# Then filter out status from message items
if isinstance(validated_input, list):
filtered_input: List[Any] = []
@ -102,7 +103,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
else:
filtered_input.append(item)
return cast(ResponseInputParam, filtered_input)
return validated_input
def transform_responses_api_request(
@ -116,6 +117,21 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""No transform applied since inputs are in OpenAI spec already"""
stripped_model_name = self.get_stripped_model_name(model)
# Azure Responses API requires flattened tools (params at top level, not nested in 'function')
if "tools" in response_api_optional_request_params and isinstance(
response_api_optional_request_params["tools"], list
):
new_tools: List[Dict[str, Any]] = []
for tool in response_api_optional_request_params["tools"]:
if isinstance(tool, dict) and "function" in tool:
new_tool: Dict[str, Any] = deepcopy(tool)
function_data = new_tool.pop("function")
new_tool.update(function_data)
new_tools.append(new_tool)
else:
new_tools.append(tool)
response_api_optional_request_params["tools"] = new_tools
return super().transform_responses_api_request(
model=stripped_model_name,
input=input,

View file

@ -1,5 +1,6 @@
import os
import sys
from copy import deepcopy
from unittest.mock import patch
import pytest
@ -191,12 +192,12 @@ def test_o_series_model_detection():
config = AzureOpenAIOSeriesResponsesAPIConfig()
# Test explicit o_series naming
assert config.is_o_series_model("o_series/gpt-o1") == True
assert config.is_o_series_model("azure/o_series/gpt-o3") == True
assert config.is_o_series_model("o_series/gpt-o1")
assert config.is_o_series_model("azure/o_series/gpt-o3")
# Test regular models
assert config.is_o_series_model("gpt-4o") == False
assert config.is_o_series_model("gpt-3.5-turbo") == False
assert not config.is_o_series_model("gpt-4o")
assert not config.is_o_series_model("gpt-3.5-turbo")
@pytest.mark.serial
@ -297,19 +298,19 @@ class TestAzureResponsesAPIConfig:
def test_azure_cancel_response_api_request(self):
"""Test Azure cancel response API request transformation"""
from litellm.types.router import GenericLiteLLMParams
response_id = "resp_test123"
api_base = "https://test.openai.azure.com/openai/responses?api-version=2024-05-01-preview"
litellm_params = GenericLiteLLMParams(api_version="2024-05-01-preview")
headers = {"Authorization": "Bearer test-key"}
url, data = self.config.transform_cancel_response_api_request(
response_id=response_id,
api_base=api_base,
litellm_params=litellm_params,
headers=headers,
)
expected_url = "https://test.openai.azure.com/openai/responses/resp_test123/cancel?api-version=2024-05-01-preview"
assert url == expected_url
assert data == {}
@ -318,7 +319,7 @@ class TestAzureResponsesAPIConfig:
"""Test Azure cancel response API response transformation"""
from unittest.mock import Mock
from litellm.types.llms.openai import ResponsesAPIResponse
# Mock response
mock_response = Mock()
mock_response.json.return_value = {
@ -330,18 +331,164 @@ class TestAzureResponsesAPIConfig:
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"status": "cancelled"
"status": "cancelled",
}
mock_response.text = "test response"
mock_response.status_code = 200
# Mock logging object
mock_logging_obj = Mock()
result = self.config.transform_cancel_response_api_response(
raw_response=mock_response,
logging_obj=mock_logging_obj,
)
assert isinstance(result, ResponsesAPIResponse)
assert result.id == "resp_test123"
assert result.id == "resp_test123"
def test_azure_responses_api_tool_flattening_nested_to_flat(self):
"""Test that nested tools are flattened correctly"""
from litellm.types.router import GenericLiteLLMParams
# Setup
nested_tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {"type": "object", "properties": {}},
},
}
]
response_api_params = {"tools": nested_tools}
litellm_params = GenericLiteLLMParams()
# Execute
self.config.transform_responses_api_request(
model=self.model,
input="test input",
response_api_optional_request_params=response_api_params,
litellm_params=litellm_params,
headers={},
)
# Verify
expected_tools = [
{
"type": "function",
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {"type": "object", "properties": {}},
}
]
assert response_api_params["tools"] == expected_tools
def test_azure_responses_api_tool_flattening_already_flat(self):
"""Test that already flat tools are passed through unchanged"""
from litellm.types.router import GenericLiteLLMParams
# Setup
flat_tools = [
{
"type": "function",
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {"type": "object", "properties": {}},
}
]
# Make a copy to check it doesn't change
response_api_params = {"tools": list(flat_tools)}
litellm_params = GenericLiteLLMParams()
# Execute
self.config.transform_responses_api_request(
model=self.model,
input="test input",
response_api_optional_request_params=response_api_params,
litellm_params=litellm_params,
headers={},
)
# Verify
assert response_api_params["tools"] == flat_tools
def test_azure_responses_api_tool_flattening_preserves_original(self):
"""Test that the original tool dictionary is not mutated"""
from litellm.types.router import GenericLiteLLMParams
# Setup
original_tool = {
"type": "function",
"function": {"name": "get_weather", "parameters": {}},
}
original_tool_copy = deepcopy(original_tool)
response_api_params = {"tools": [original_tool]}
litellm_params = GenericLiteLLMParams()
# Execute
self.config.transform_responses_api_request(
model=self.model,
input="test input",
response_api_optional_request_params=response_api_params,
litellm_params=litellm_params,
headers={},
)
assert original_tool == original_tool_copy
def test_azure_responses_api_tool_flattening_mixed_tools(self):
"""Test mixed nested and flat tools"""
from litellm.types.router import GenericLiteLLMParams
# Setup
nested_tool = {
"type": "function",
"function": {"name": "nested", "parameters": {}},
}
flat_tool = {"type": "function", "name": "flat", "parameters": {}}
response_api_params = {"tools": [nested_tool, flat_tool]}
litellm_params = GenericLiteLLMParams()
# Execute
self.config.transform_responses_api_request(
model=self.model,
input="test input",
response_api_optional_request_params=response_api_params,
litellm_params=litellm_params,
headers={},
)
# Verify
assert len(response_api_params["tools"]) == 2
# First tool should be flattened
assert "function" not in response_api_params["tools"][0]
assert response_api_params["tools"][0]["name"] == "nested"
# Second tool should remain as is
assert response_api_params["tools"][1] == flat_tool
def test_azure_responses_api_tool_flattening_no_tools(self):
"""Test handling when no tools are present"""
from litellm.types.router import GenericLiteLLMParams
# Setup
response_api_params = {}
litellm_params = GenericLiteLLMParams()
# Execute - should not crash
self.config.transform_responses_api_request(
model=self.model,
input="test input",
response_api_optional_request_params=response_api_params,
litellm_params=litellm_params,
headers={},
)
assert "tools" not in response_api_params