fix(azure): flatten top-level tool schema combinators on Azure chat completions

Azure's chat completions validator rejects tool parameters carrying a
top-level anyOf/oneOf/allOf for every model family. AzureOpenAIConfig and
the o-series config now flatten them via the shared helper moved to
prompt_templates common_utils. Requests bridged to the Responses API for
gpt-5.4+ with reasoning active keep the union, which that surface accepts
This commit is contained in:
mateo-berri 2026-08-29 21:27:57 -07:00
parent 1c4674441c
commit b418ccd738
7 changed files with 246 additions and 16 deletions

View file

@ -1246,6 +1246,19 @@ def flatten_top_level_schema_combinators(schema: Mapping[str, object]) -> Mappin
return _flatten_schema_against_root(schema, schema, frozenset(), 0, {}) # mutable-ok: fresh per-call $ref memo
def tool_with_flattened_parameters(tool: Mapping[str, object]) -> Mapping[str, object]:
function: Final = tool.get("function")
if not isinstance(function, dict):
return tool
parameters: Final = function.get("parameters")
if not isinstance(parameters, dict):
return tool
flattened: Final = flatten_top_level_schema_combinators(parameters)
if flattened is parameters:
return tool
return {**tool, "function": {**function, "parameters": flattened}} # mutable-ok: request tools are JSON dicts
def _get_image_mime_type_from_url(url: str) -> str | None:
"""
Get mime type for common image URLs

View file

@ -1,3 +1,5 @@
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
from httpx._models import Headers, Response
@ -6,6 +8,7 @@ import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_tool_reference_parts_from_tool_messages,
hoist_images_from_tool_messages,
tool_with_flattened_parameters,
)
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_azure_openai_messages,
@ -32,6 +35,19 @@ else:
LoggingClass = Any
_NO_TOOLS_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
def flattened_tools_update(optional_params: Mapping[str, object]) -> Mapping[str, object]:
tools: Final = optional_params.get("tools")
if not isinstance(tools, list):
return _NO_TOOLS_UPDATE
flattened: Final = [ # mutable-ok: request tools are a JSON list
tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools
]
return MappingProxyType({"tools": flattened})
class AzureOpenAIConfig(BaseConfig):
"""
Reference: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions
@ -261,6 +277,7 @@ class AzureOpenAIConfig(BaseConfig):
"model": model,
"messages": azure_messages,
**optional_params,
**flattened_tools_update(optional_params),
}
def transform_response(

View file

@ -20,6 +20,7 @@ from litellm.types.llms.openai import AllMessageValues
from litellm.utils import get_model_info, supports_reasoning
from ...openai.chat.o_series_transformation import OpenAIOSeriesConfig
from .gpt_transformation import flattened_tools_update
class AzureOpenAIO1Config(OpenAIOSeriesConfig):
@ -108,4 +109,8 @@ class AzureOpenAIO1Config(OpenAIOSeriesConfig):
headers: dict,
) -> dict:
model = model.replace("o_series/", "") # handle o_series/my-random-deployment-name
return super().transform_request(model, messages, optional_params, litellm_params, headers)
flattened_params: Final = { # mutable-ok: transform_request's contract takes a plain JSON params dict
**optional_params,
**flattened_tools_update(optional_params),
}
return super().transform_request(model, messages, flattened_params, litellm_params, headers)

View file

@ -20,9 +20,9 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_tool_reference_parts_from_tool_messages,
flatten_top_level_schema_combinators,
get_tool_call_names,
hoist_images_from_tool_messages,
tool_with_flattened_parameters,
)
from litellm.litellm_core_utils.prompt_templates.image_handling import (
async_convert_url_to_base64,
@ -70,19 +70,6 @@ else:
_NO_TOOLS_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
def _tool_with_flattened_parameters(tool: Mapping[str, object]) -> Mapping[str, object]:
function: Final = tool.get("function")
if not isinstance(function, dict):
return tool
parameters: Final = function.get("parameters")
if not isinstance(parameters, dict):
return tool
flattened: Final = flatten_top_level_schema_combinators(parameters)
if flattened is parameters:
return tool
return {**tool, "function": {**function, "parameters": flattened}} # mutable-ok: request tools are JSON dicts
class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
"""
Reference: https://platform.openai.com/docs/api-reference/chat/create
@ -462,7 +449,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
):
return _NO_TOOLS_UPDATE
flattened: Final = [ # mutable-ok: request tools are a JSON list
_tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools
tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools
]
return MappingProxyType({"tools": flattened})

View file

@ -1433,3 +1433,77 @@ class TestFlattenTopLevelSchemaCombinators:
flatten_top_level_schema_combinators(schema)
assert schema == snapshot
class TestToolWithFlattenedParameters:
def _anyof_tool(self):
return {
"type": "function",
"function": {
"name": "automation_update",
"description": "Update an automation",
"parameters": {
"type": "object",
"anyOf": [
{
"properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}},
"required": ["id", "enabled"],
},
{
"properties": {"id": {"type": "string"}, "schedule": {"type": "string"}},
"required": ["id", "schedule"],
},
],
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
},
}
def test_flattens_anyof_parameters_into_new_tool(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
tool_with_flattened_parameters,
)
tool = self._anyof_tool()
result = tool_with_flattened_parameters(tool)
assert result is not tool
parameters = result["function"]["parameters"]
assert "anyOf" not in parameters
assert parameters["type"] == "object"
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}
assert parameters["required"] == ["id"]
assert result["function"]["name"] == "automation_update"
assert tool == self._anyof_tool()
def test_clean_parameters_return_the_same_tool_object(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
tool_with_flattened_parameters,
)
tool = {
"type": "function",
"function": {
"name": "lookup",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]},
},
}
assert tool_with_flattened_parameters(tool) is tool
@pytest.mark.parametrize(
"tool",
[
{"type": "function"},
{"type": "function", "function": "not-a-dict"},
{"type": "function", "function": {"name": "no_params"}},
{"type": "function", "function": {"name": "bad_params", "parameters": "not-a-dict"}},
],
)
def test_non_dict_function_or_parameters_return_the_same_tool_object(self, tool):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
tool_with_flattened_parameters,
)
assert tool_with_flattened_parameters(tool) is tool

View file

@ -11,6 +11,7 @@ sys.path.insert(
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_BOUNDARY
from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config
from litellm.llms.azure.chat.gpt_transformation import AzureOpenAIConfig
from litellm.utils import get_optional_params
@ -195,3 +196,91 @@ def test_azure_gpt_5_takes_the_reasoning_path() -> None:
assert "presence_penalty" not in mapped
assert "logit_bias" not in mapped
assert "reasoning_effort" in supported
class TestAzureToolSchemaCombinatorFlattening:
"""
Regression tests for LIT-6510: Azure's chat completions validator rejects
tool parameters carrying a top-level anyOf/oneOf/allOf for every model
family, so AzureOpenAIConfig.transform_request must flatten them.
"""
@staticmethod
def _anyof_tool():
return {
"type": "function",
"function": {
"name": "automation_update",
"description": "Update an automation",
"parameters": {
"type": "object",
"anyOf": [
{
"properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}},
"required": ["id", "enabled"],
},
{
"properties": {"id": {"type": "string"}, "schedule": {"type": "string"}},
"required": ["id", "schedule"],
},
],
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
},
}
def _transform(self, config, model, tools):
return config.transform_request(
model=model,
messages=[{"role": "user", "content": "hi"}],
optional_params={"tools": tools},
litellm_params={"custom_llm_provider": "azure"},
headers={},
)
def test_transform_request_flattens_top_level_anyof(self):
request = self._transform(AzureOpenAIConfig(), "gpt-4o", [self._anyof_tool()])
parameters = request["tools"][0]["function"]["parameters"]
assert "anyOf" not in parameters
assert parameters["type"] == "object"
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}
assert parameters["required"] == ["id"]
assert request["tools"][0]["function"]["name"] == "automation_update"
def test_gpt5_config_flattens_via_shared_transform(self):
request = self._transform(AzureOpenAIGPT5Config(), "gpt-5.4-mini", [self._anyof_tool()])
parameters = request["tools"][0]["function"]["parameters"]
assert "anyOf" not in parameters
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}
def test_caller_tool_dict_is_not_mutated(self):
tool = self._anyof_tool()
self._transform(AzureOpenAIConfig(), "gpt-4o", [tool])
assert tool == self._anyof_tool()
def test_clean_object_schema_passes_through_as_same_object(self):
tool = {
"type": "function",
"function": {
"name": "lookup",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]},
},
}
request = self._transform(AzureOpenAIConfig(), "gpt-4o", [tool])
assert request["tools"][0] is tool
def test_non_dict_tool_entries_pass_through_unchanged(self):
request = self._transform(AzureOpenAIConfig(), "gpt-4o", ["not-a-tool"])
assert request["tools"] == ["not-a-tool"]
def test_request_without_tools_is_unchanged(self):
request = AzureOpenAIConfig().transform_request(
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
optional_params={"temperature": 0.2},
litellm_params={"custom_llm_provider": "azure"},
headers={},
)
assert "tools" not in request
assert request["temperature"] == 0.2

View file

@ -23,3 +23,48 @@ async def test_azure_chat_o_series_transformation():
)
print(response)
assert response["model"] == "web-interface-o1-mini"
def test_azure_o_series_transform_request_flattens_top_level_anyof():
"""Regression test for LIT-6510: the o-series super() chain ends in
OpenAIGPTConfig, whose flatten gate skips provider 'azure', so
AzureOpenAIO1Config must flatten tool schema combinators itself."""
tool = {
"type": "function",
"function": {
"name": "automation_update",
"description": "Update an automation",
"parameters": {
"type": "object",
"anyOf": [
{
"properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}},
"required": ["id", "enabled"],
},
{
"properties": {"id": {"type": "string"}, "schedule": {"type": "string"}},
"required": ["id", "schedule"],
},
],
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
},
}
optional_params = {"tools": [tool]}
request = AzureOpenAIO1Config().transform_request(
model="o3-mini",
messages=[{"role": "user", "content": "hi"}],
optional_params=optional_params,
litellm_params={"custom_llm_provider": "azure"},
headers={},
)
parameters = request["tools"][0]["function"]["parameters"]
assert "anyOf" not in parameters
assert parameters["type"] == "object"
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}
assert parameters["required"] == ["id"]
assert "anyOf" in tool["function"]["parameters"]
assert optional_params["tools"][0] is tool