From 9b8ad46f37545985cd2173c3ede0957c082ce55b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:11:04 -0700 Subject: [PATCH] fix(openai): flatten top-level anyOf/oneOf/allOf in Responses API tool schemas OpenAI's function-calling validator rejects tool parameters carrying oneOf/anyOf/allOf/enum/const/not at the top level, while the ChatGPT backend Codex talks to natively accepts them, so an MCP tool declaring a top-level union 400s through the proxy. Merge the branches into the object schema for OpenAI itself only, walking the namespace-nested tools current Codex builds send, on both /v1/responses and /v1/responses/compact --- .../prompt_templates/common_utils.py | 86 +++++++++++ .../llms/openai/responses/transformation.py | 62 +++++++- ...ore_utils_prompt_templates_common_utils.py | 137 ++++++++++++++++++ .../test_openai_responses_transformation.py | 135 +++++++++++++++++ 4 files changed, 416 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index f0e5086b660..3a8bf8f328c 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -10,6 +10,7 @@ from collections.abc import Iterable, Mapping, Sequence from itertools import groupby from os import PathLike from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, cast from openai.types.chat.chat_completion_custom_tool_param import ( @@ -1089,6 +1090,91 @@ def sanitize_input_schema_for_anthropic(input_schema: dict) -> "AnthropicInputSc return AnthropicInputSchema(**filtered) +_TOP_LEVEL_SCHEMA_COMBINATORS: Final = ("allOf", "anyOf", "oneOf") +_OPENAI_REJECTED_TOP_LEVEL_SCHEMA_KEYS: Final = ("enum", "const", "not") +_EMPTY_SCHEMA: Final[Mapping[str, object]] = MappingProxyType({}) + + +def _schema_properties(schema: Mapping[str, object]) -> Mapping[str, object]: + properties: Final = schema.get("properties") + return properties if isinstance(properties, dict) else _EMPTY_SCHEMA + + +def _schema_branches(schema: Mapping[str, object], combinator: str) -> tuple[Mapping[str, object], ...]: + branches: Final = schema.get(combinator) + if not isinstance(branches, list): + return () + return tuple(branch for branch in branches if isinstance(branch, dict)) + + +def _schema_required_names(schema: Mapping[str, object]) -> frozenset[str]: + required: Final = schema.get("required") + if not isinstance(required, list): + return frozenset() + return frozenset(name for name in required if isinstance(name, str)) + + +def _combinator_required_names(combinator: str, branches: tuple[Mapping[str, object], ...]) -> frozenset[str]: + branch_names: Final = tuple(_schema_required_names(branch) for branch in branches) + if not branch_names: + return frozenset() + if combinator == "allOf": + return branch_names[0].union(*branch_names[1:]) + return branch_names[0].intersection(*branch_names[1:]) + + +def flatten_top_level_schema_combinators(schema: Mapping[str, object]) -> Mapping[str, object]: + """Merge top-level ``allOf``/``anyOf``/``oneOf`` branches into an object tool schema. + + OpenAI's function-calling validator rejects tool ``parameters`` carrying + 'oneOf'/'anyOf'/'allOf'/'enum'/'const'/'not' at the top level (nested uses + are accepted), while lenient backends such as the ChatGPT backend Codex + talks to natively accept them, so an MCP tool declaring a top-level union + 400s through LiteLLM. Branch properties merge without clobbering (the + top-level schema wins, then earlier branches); a missing ``required`` + becomes the intersection of the branch lists for anyOf/oneOf and their + union for allOf. Non-object schemas pass through unchanged and the input + is never mutated. + """ + branch_groups: Final = tuple( + (combinator, _schema_branches(schema, combinator)) + for combinator in _TOP_LEVEL_SCHEMA_COMBINATORS + if isinstance(schema.get(combinator), list) + ) + dropped: Final = ( + *(combinator for combinator, _ in branch_groups), + *(key for key in _OPENAI_REJECTED_TOP_LEVEL_SCHEMA_KEYS if key in schema), + ) + if not dropped: + return schema + + branches: Final = tuple(branch for _, group in branch_groups for branch in group) + is_object_schema: Final = schema.get("type") == "object" or ( + "type" not in schema and branches != () and all("properties" in branch for branch in branches) + ) + if not is_object_schema: + return schema + + merged_properties: Final = { # mutable-ok: tool parameters are JSON dicts + name: value for source in (*reversed(branches), schema) for name, value in _schema_properties(source).items() + } + fallback_required: Final = frozenset( + name for combinator, group in branch_groups for name in _combinator_required_names(combinator, group) + ) + kept: Final = MappingProxyType({key: value for key, value in schema.items() if key not in dropped}) + required_update: Final = ( + MappingProxyType({"required": sorted(fallback_required)}) + if "required" not in kept and fallback_required + else _EMPTY_SCHEMA + ) + return { # mutable-ok: tool parameters are JSON dicts + **kept, + "type": "object", + "properties": merged_properties, + **required_update, + } + + def _get_image_mime_type_from_url(url: str) -> str | None: """ Get mime type for common image URLs diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 2fa44cfc2e3..07a9a21bf7a 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, cast, get_type_hints import httpx @@ -29,6 +31,8 @@ if TYPE_CHECKING: else: LiteLLMLoggingObj = Any +_NO_TOOL_UPDATE: Final[Mapping[str, object]] = MappingProxyType({}) + class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): @property @@ -167,8 +171,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) - if tools is not None: - response_api_optional_request_params["tools"] = tools + sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai(tools) + if sanitized_tools is not None: + response_api_optional_request_params["tools"] = sanitized_tools final_request_params: Final = dict( ResponsesAPIRequestParams(model=model, input=input, **response_api_optional_request_params) ) @@ -207,6 +212,54 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): return input, tools + def _flatten_tool_schema_combinators_for_openai( + self, + tools: list[ALL_RESPONSES_API_TOOL_PARAMS] | None, # mutable-ok: request tools are a JSON list + ) -> list[ALL_RESPONSES_API_TOOL_PARAMS] | None: # mutable-ok: request tools are a JSON list + """Flatten top-level schema combinators for OpenAI itself only. + + OpenAI-compatible backends reusing this config (and the ChatGPT backend + Codex talks to natively) accept them. Codex wraps MCP tools inside + namespace entries, so nested ``tools`` arrays are walked too. + """ + if tools is None or self.custom_llm_provider != LlmProviders.OPENAI: + return tools + flattened: Final = [ # mutable-ok: request tools are a JSON list + self._flattened_tool_entry(tool) for tool in tools + ] + return cast("list[ALL_RESPONSES_API_TOOL_PARAMS]", flattened) # cast-ok: dict spread keeps each tool's shape + + @staticmethod + def _flattened_tool_entry( + entry: Mapping[str, object], + ) -> dict[str, object]: # mutable-ok: request tools are JSON dicts + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + parameters: Final = entry.get("parameters") + nested_tools: Final = entry.get("tools") + parameters_update: Final = ( + MappingProxyType({"parameters": flatten_top_level_schema_combinators(parameters)}) + if isinstance(parameters, dict) + else _NO_TOOL_UPDATE + ) + tools_update: Final = ( + MappingProxyType({"tools": OpenAIResponsesAPIConfig._flattened_nested_tools(nested_tools)}) + if isinstance(nested_tools, list) + else _NO_TOOL_UPDATE + ) + return {**entry, **parameters_update, **tools_update} # mutable-ok: request tools are JSON dicts + + @staticmethod + def _flattened_nested_tools( + nested_tools: Sequence[object], + ) -> list[object]: # mutable-ok: namespace tools are a JSON list + return [ # mutable-ok: namespace tools are a JSON list + OpenAIResponsesAPIConfig._flattened_tool_entry(item) if isinstance(item, dict) else item + for item in nested_tools + ] + def _validate_input_param(self, input: str | ResponseInputParam) -> str | ResponseInputParam: """ Ensure all input fields if pydantic are converted to dict @@ -646,8 +699,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) - if tools is not None: - response_api_optional_request_params["tools"] = tools + sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai(tools) + 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)) return url, data diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index aec6d12069f..ae3e49a1e44 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -1094,3 +1094,140 @@ def test_drop_tool_reference_parts_leaves_non_tool_messages_alone(): assert result[0] == user_message assert result[2]["content"] == "" + + +class TestFlattenTopLevelSchemaCombinators: + def _customer_anyof_schema(self): + return { + "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_merges_anyof_branches_into_object_schema(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + result = flatten_top_level_schema_combinators(self._customer_anyof_schema()) + + assert "anyOf" not in result + assert result["type"] == "object" + assert set(result["properties"]) == {"id", "enabled", "schedule"} + assert result["properties"]["enabled"] == {"type": "boolean"} + assert result["required"] == ["id"] + + def test_typeless_anyof_of_object_branches_gets_intersected_required(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + schema = { + "anyOf": [ + {"properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}}, "required": ["id", "enabled"]}, + {"properties": {"id": {"type": "string"}, "schedule": {"type": "string"}}, "required": ["id", "schedule"]}, + ] + } + + result = flatten_top_level_schema_combinators(schema) + + assert result["type"] == "object" + assert "anyOf" not in result + assert result["required"] == ["id"] + + def test_allof_required_is_the_union_of_branches(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + schema = { + "type": "object", + "allOf": [ + {"properties": {"id": {"type": "string"}}, "required": ["id"]}, + {"properties": {"enabled": {"type": "boolean"}}, "required": ["enabled"]}, + ], + } + + result = flatten_top_level_schema_combinators(schema) + + assert "allOf" not in result + assert result["required"] == ["enabled", "id"] + assert set(result["properties"]) == {"id", "enabled"} + + def test_top_level_schema_wins_property_collisions(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + schema = { + "type": "object", + "anyOf": [ + {"properties": {"id": {"type": "integer"}}}, + {"properties": {"id": {"type": "number"}}}, + ], + "properties": {"id": {"type": "string"}}, + } + + result = flatten_top_level_schema_combinators(schema) + + assert result["properties"]["id"] == {"type": "string"} + + def test_drops_openai_rejected_scalar_keys_on_object_schema(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + schema = { + "type": "object", + "properties": {"id": {"type": "string"}}, + "enum": [{"id": "a"}], + "const": {"id": "a"}, + "not": {"required": ["other"]}, + } + + result = flatten_top_level_schema_combinators(schema) + + assert "enum" not in result + assert "const" not in result + assert "not" not in result + assert result["properties"] == {"id": {"type": "string"}} + + def test_non_object_union_passes_through_unchanged(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + schema = {"anyOf": [{"type": "string"}, {"type": "number"}]} + + assert flatten_top_level_schema_combinators(schema) is schema + + def test_schema_without_rejected_keys_is_returned_as_is(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + schema = {"type": "object", "properties": {"nested": {"anyOf": [{"type": "string"}, {"type": "null"}]}}} + + assert flatten_top_level_schema_combinators(schema) is schema + + def test_input_schema_is_never_mutated(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + flatten_top_level_schema_combinators, + ) + + schema = self._customer_anyof_schema() + snapshot = json.loads(json.dumps(schema)) + + flatten_top_level_schema_combinators(schema) + + assert schema == snapshot diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index e314b94444b..8135ec643e8 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -1626,3 +1626,138 @@ class TestResponsesSurfaceSharesTheEffortRule: drop_params=True, ) assert ("temperature" in mapped) is temperature_survives + + +class TestFlattenToolSchemaCombinatorsWiring: + """Regression tests for MCP tools with a top-level anyOf schema (Codex Desktop). + + OpenAI's /v1/responses rejects function tool parameters carrying + 'oneOf'/'anyOf'/'allOf'/'enum'/'const'/'not' at the top level, while the + ChatGPT backend Codex uses natively accepts them, so those tools 400'd + through the proxy with "Invalid schema for function ...". + """ + + def _anyof_parameters(self): + return { + "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 _flat_function_tool(self): + return { + "type": "function", + "name": "mcp__codex_app__automation_update", + "description": "Update an automation", + "parameters": self._anyof_parameters(), + "strict": False, + } + + def _codex_namespace_tool(self): + return { + "type": "namespace", + "name": "mcp__codex_app", + "tools": [ + { + "name": "automation_update", + "description": "Update an automation", + "parameters": self._anyof_parameters(), + "strict": False, + } + ], + } + + def test_openai_flattens_top_level_anyof_on_flat_function_tool(self): + result = OpenAIResponsesAPIConfig().transform_responses_api_request( + model="gpt-4o", + input="hi", + response_api_optional_request_params={"tools": [self._flat_function_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"] + assert json.loads(json.dumps(result["tools"])) == result["tools"] + + def test_openai_flattens_anyof_inside_codex_namespace_tools(self): + result = OpenAIResponsesAPIConfig().transform_responses_api_request( + model="gpt-4o", + input="hi", + response_api_optional_request_params={"tools": [self._codex_namespace_tool()]}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + nested_parameters = result["tools"][0]["tools"][0]["parameters"] + assert "anyOf" not in nested_parameters + assert set(nested_parameters["properties"]) == {"id", "enabled", "schedule"} + assert json.loads(json.dumps(result["tools"])) == result["tools"] + + def test_openai_compact_request_flattens_top_level_anyof(self): + _, data = OpenAIResponsesAPIConfig().transform_compact_response_api_request( + model="gpt-4o", + input="hi", + response_api_optional_request_params={"tools": [self._flat_function_tool()]}, + api_base="https://api.openai.com/v1/responses", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "anyOf" not in data["tools"][0]["parameters"] + + def test_openai_leaves_tools_without_rejected_keys_alone(self): + clean_tool = { + "type": "function", + "name": "get_weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}, + } + + result = OpenAIResponsesAPIConfig().transform_responses_api_request( + model="gpt-4o", + input="hi", + response_api_optional_request_params={"tools": [clean_tool]}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["tools"][0]["parameters"] == {"type": "object", "properties": {"city": {"type": "string"}}} + + def test_openai_does_not_mutate_caller_tool_dicts(self): + tool = self._flat_function_tool() + + OpenAIResponsesAPIConfig().transform_responses_api_request( + model="gpt-4o", + input="hi", + response_api_optional_request_params={"tools": [tool]}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "anyOf" in tool["parameters"] + + def test_non_openai_subclass_does_not_flatten(self): + from litellm.llms.hosted_vllm.responses.transformation import HostedVLLMResponsesAPIConfig + + result = HostedVLLMResponsesAPIConfig().transform_responses_api_request( + model="hosted_vllm/qwen", + input="hi", + response_api_optional_request_params={"tools": [self._flat_function_tool()]}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "anyOf" in result["tools"][0]["parameters"]