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
This commit is contained in:
mateo-berri 2026-08-29 13:11:04 -07:00
parent fa25ff2a2e
commit 9b8ad46f37
4 changed files with 416 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"]