mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
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:
parent
1c4674441c
commit
b418ccd738
7 changed files with 246 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue