fix(azure): flatten top-level tool schema combinators for Azure Responses GPT-4-family deployments

This commit is contained in:
mateo-berri 2026-08-29 16:23:01 -07:00
parent 9448293903
commit af186eaaf3
2 changed files with 96 additions and 4 deletions

View file

@ -33,6 +33,7 @@ else:
_NO_TOOL_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
_MODEL_FAMILIES_REJECTING_TOP_LEVEL_SCHEMA_COMBINATORS: Final = ("gpt-4", "gpt-3.5", "chatgpt-4o", "o1", "o3", "o4")
_PROVIDERS_WITH_COMBINATOR_REJECTING_VALIDATOR: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI})
class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
@ -172,7 +173,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
input = self._validate_input_param(input)
tools = response_api_optional_request_params.get("tools")
input, tools = self.remove_cache_control_flag_from_input_and_tools(model=model, input=input, tools=tools)
sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai(model=model, tools=tools)
sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai(
model=model, tools=tools, litellm_params=litellm_params
)
if sanitized_tools is not None:
response_api_optional_request_params["tools"] = sanitized_tools
final_request_params: Final = dict(
@ -217,6 +220,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
self,
model: str,
tools: list[ALL_RESPONSES_API_TOOL_PARAMS] | None, # mutable-ok: request tools are a JSON list
litellm_params: GenericLiteLLMParams,
) -> list[ALL_RESPONSES_API_TOOL_PARAMS] | None: # mutable-ok: request tools are a JSON list
"""Flatten top-level schema combinators only where OpenAI's validator rejects them.
@ -224,10 +228,14 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
Codex talks to natively) accept them, and so do GPT-5 and later models,
which also call tools better with the union intact. Codex wraps MCP tools
inside namespace entries, so nested ``tools`` arrays are walked too.
Azure OpenAI shares the validator but names deployments arbitrarily, so
the router's declared ``model_info.base_model`` wins over the deployment
name and an unrecognized name without one is left untouched.
"""
if tools is None or self.custom_llm_provider != LlmProviders.OPENAI:
if tools is None or self.custom_llm_provider not in _PROVIDERS_WITH_COMBINATOR_REJECTING_VALIDATOR:
return tools
if not self._rejects_top_level_schema_combinators(model):
gate_model: Final = self._combinator_gate_model(model=model, litellm_params=litellm_params)
if not self._rejects_top_level_schema_combinators(gate_model):
return tools
flattened: Final = [ # mutable-ok: request tools are a JSON list
self._flattened_tool_or_passthrough(tool) for tool in tools
@ -244,6 +252,12 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
base_model: Final = bare_model.split(":")[1] if bare_model.startswith("ft:") else bare_model
return base_model.startswith(_MODEL_FAMILIES_REJECTING_TOP_LEVEL_SCHEMA_COMBINATORS)
@staticmethod
def _combinator_gate_model(model: str, litellm_params: GenericLiteLLMParams) -> str:
model_info: Final[object] = getattr(litellm_params, "model_info", None)
base_model: Final[object] = model_info.get("base_model") if isinstance(model_info, dict) else None
return base_model if isinstance(base_model, str) and base_model else model
@staticmethod
def _flattened_tool_entry(
entry: Mapping[str, object],
@ -714,7 +728,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
input = self._validate_input_param(input)
tools = response_api_optional_request_params.get("tools")
input, tools = self.remove_cache_control_flag_from_input_and_tools(model=model, input=input, tools=tools)
sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai(model=model, tools=tools)
sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai(
model=model, tools=tools, litellm_params=litellm_params
)
if sanitized_tools is not None:
response_api_optional_request_params["tools"] = sanitized_tools
data: Final = dict(ResponsesAPIRequestParams(model=model, input=input, **response_api_optional_request_params))

View file

@ -537,3 +537,79 @@ class TestAzureResponsesAPIConfig:
"""
supported = self.config.get_supported_openai_params(self.model)
assert "context_management" not in supported
def _anyof_tool(self):
return {
"type": "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_azure_flattens_top_level_anyof_for_gpt4_family_deployment_name(self):
result = self.config.transform_responses_api_request(
model="gpt-4o",
input="hi",
response_api_optional_request_params={"tools": [self._anyof_tool()]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
parameters = result["tools"][0]["parameters"]
assert "anyOf" not in parameters
assert parameters["type"] == "object"
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}
assert parameters["required"] == ["id"]
def test_azure_flattens_via_base_model_for_arbitrary_deployment_name(self):
result = self.config.transform_responses_api_request(
model="my-eastus-deployment",
input="hi",
response_api_optional_request_params={"tools": [self._anyof_tool()]},
litellm_params=GenericLiteLLMParams(model_info={"base_model": "azure/gpt-4o"}),
headers={},
)
assert "anyOf" not in result["tools"][0]["parameters"]
def test_azure_keeps_combinators_for_gpt5_base_model(self):
tool = self._anyof_tool()
result = self.config.transform_responses_api_request(
model="my-eastus-deployment",
input="hi",
response_api_optional_request_params={"tools": [tool]},
litellm_params=GenericLiteLLMParams(model_info={"base_model": "azure/gpt-5.4-mini"}),
headers={},
)
assert result["tools"][0] is tool
assert "anyOf" in result["tools"][0]["parameters"]
def test_azure_keeps_combinators_for_unrecognized_deployment_without_base_model(self):
tool = self._anyof_tool()
result = self.config.transform_responses_api_request(
model="my-eastus-deployment",
input="hi",
response_api_optional_request_params={"tools": [tool]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert result["tools"][0] is tool
assert "anyOf" in result["tools"][0]["parameters"]