diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 27e4b7dc4a6..eac844a790d 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -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)) diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/test_litellm/llms/azure/response/test_azure_transformation.py index da44394d11d..f6bbf685f26 100644 --- a/tests/test_litellm/llms/azure/response/test_azure_transformation.py +++ b/tests/test_litellm/llms/azure/response/test_azure_transformation.py @@ -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"]