Merge pull request #38792 from BerriAI/litellm_fix_responses_anyof_tool_schema

fix(openai): flatten top-level anyOf/oneOf/allOf in Responses API tool schemas
This commit is contained in:
Mateo Wang 2026-08-29 16:45:11 -07:00 committed by GitHub
commit ecd42ea77a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 760 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,162 @@ 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")
_LOCAL_SCHEMA_REF_PREFIXES: Final = (("#/$defs/", "$defs"), ("#/definitions/", "definitions"))
_MAX_SCHEMA_FLATTEN_DEPTH: Final = 32
_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[object, ...]:
branches: Final = schema.get(combinator)
return tuple(branches) if isinstance(branches, list) else ()
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 _resolve_local_schema_ref(root: Mapping[str, object], ref: str) -> Mapping[str, object] | None:
matched: Final = next(
((prefix, container) for prefix, container in _LOCAL_SCHEMA_REF_PREFIXES if ref.startswith(prefix)),
None,
)
if matched is None:
return None
prefix, container = matched
definitions: Final = root.get(container)
if not isinstance(definitions, dict):
return None
target: Final = definitions.get(ref[len(prefix) :])
return target if isinstance(target, dict) else None
def _mergeable_branch(
root: Mapping[str, object],
branch: object,
seen_refs: frozenset[str],
depth: int,
expanded_refs: dict[str, Mapping[str, object] | None], # mutable-ok: per-call memo bounding repeated $ref work
) -> Mapping[str, object] | None:
if not isinstance(branch, dict) or depth > _MAX_SCHEMA_FLATTEN_DEPTH:
return None
ref: Final = branch.get("$ref")
if not isinstance(ref, str):
flattened: Final = _flatten_schema_against_root(branch, root, seen_refs, depth, expanded_refs)
if any(combinator in flattened for combinator in _TOP_LEVEL_SCHEMA_COMBINATORS):
return None
return flattened
if ref in expanded_refs:
return expanded_refs[ref]
if ref in seen_refs:
return None
target: Final = _resolve_local_schema_ref(root, ref)
expanded: Final = (
None
if target is None
else _mergeable_branch(root, target, seen_refs | frozenset((ref,)), depth + 1, expanded_refs)
)
expanded_refs[ref] = expanded
return expanded
def _is_object_schema(schema: Mapping[str, object]) -> bool:
return schema.get("type") == "object" or ("type" not in schema and "properties" in schema)
def _flatten_schema_against_root(
schema: Mapping[str, object],
root: Mapping[str, object],
seen_refs: frozenset[str],
depth: int,
expanded_refs: dict[str, Mapping[str, object] | None], # mutable-ok: per-call memo bounding repeated $ref work
) -> Mapping[str, object]:
raw_branch_groups: Final = tuple(
(
combinator,
tuple(
_mergeable_branch(root, branch, seen_refs, depth + 1, expanded_refs)
for branch in _schema_branches(schema, combinator)
),
)
for combinator in _TOP_LEVEL_SCHEMA_COMBINATORS
if isinstance(schema.get(combinator), list)
)
dropped: Final = (
*(combinator for combinator, _ in raw_branch_groups),
*(key for key in _OPENAI_REJECTED_TOP_LEVEL_SCHEMA_KEYS if key in schema),
)
if not dropped:
return schema
if any(branch is None for _, group in raw_branch_groups for branch in group):
return schema
branch_groups: Final = tuple(
(combinator, tuple(branch for branch in group if branch is not None)) for combinator, group in raw_branch_groups
)
branches: Final = tuple(branch for _, group in branch_groups for branch in group)
is_object_schema: Final = _is_object_schema(schema) or (
"type" not in schema and branches != () and all(_is_object_schema(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()
}
required_names: Final = _schema_required_names(schema).union(
*(_combinator_required_names(combinator, group) for combinator, group in branch_groups)
)
kept: Final = MappingProxyType({key: value for key, value in schema.items() if key not in dropped})
required_update: Final = MappingProxyType({"required": sorted(required_names)}) if required_names else _EMPTY_SCHEMA
return { # mutable-ok: tool parameters are JSON dicts
**kept,
"type": "object",
"properties": merged_properties,
**required_update,
}
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); ``required`` becomes the
top-level list plus the intersection of the branch lists for anyOf/oneOf
or their union for allOf. Branches that are local ``$ref``s
(``#/$defs/...`` or ``#/definitions/...``) are resolved first, each ref
at most once per call, and branches that are themselves combinators are
flattened recursively up to a fixed depth; a branch that cannot be fully
merged (a boolean schema, an external or cyclic ``$ref``, a non-object
union, or nesting past the depth cap) leaves the whole schema untouched so
OpenAI's own validation still applies. Non-object schemas pass through
unchanged and the input is never mutated.
"""
return _flatten_schema_against_root(schema, schema, frozenset(), 0, {}) # mutable-ok: fresh per-call $ref memo
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,9 @@ if TYPE_CHECKING:
else:
LiteLLMLoggingObj = Any
_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")
class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
@property
@ -167,8 +172,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(model=model, tools=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 +213,68 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
return input, tools
def _flatten_tool_schema_combinators_for_openai(
self,
model: str,
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 only where OpenAI's validator rejects them.
OpenAI-compatible backends reusing this config (and the ChatGPT backend
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.
"""
if tools is None or self.custom_llm_provider != LlmProviders.OPENAI:
return tools
if not self._rejects_top_level_schema_combinators(model):
return tools
flattened: Final = [ # mutable-ok: request tools are a JSON list
self._flattened_tool_or_passthrough(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_or_passthrough(tool: object) -> object:
return OpenAIResponsesAPIConfig._flattened_tool_entry(tool) if isinstance(tool, dict) else tool
@staticmethod
def _rejects_top_level_schema_combinators(model: str) -> bool:
bare_model: Final = model.split("/")[-1]
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 _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 +714,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(model=model, tools=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

@ -57,6 +57,7 @@ IGNORE_FUNCTIONS = [
"_filter_argument_value", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by blocking the tool call at the cap.
"_redact_scanned_content", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by returning "[REDACTED]" at the cap.
"_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap.
"_mergeable_branch", # max depth set (_MAX_SCHEMA_FLATTEN_DEPTH=32) plus a seen_refs cycle guard; passes the schema through untouched at the cap.
"json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
"with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.

View file

@ -1,3 +1,4 @@
import functools
import json
import os
from unittest.mock import MagicMock, patch
@ -1094,3 +1095,341 @@ 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_resolves_local_ref_branches_from_defs(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
schema = {
"anyOf": [{"$ref": "#/$defs/Enable"}, {"$ref": "#/$defs/Schedule"}],
"$defs": {
"Enable": {
"properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}},
"required": ["id", "enabled"],
},
"Schedule": {
"properties": {"id": {"type": "string"}, "schedule": {"type": "string"}},
"required": ["id", "schedule"],
},
},
}
result = flatten_top_level_schema_combinators(schema)
assert "anyOf" not in result
assert result["type"] == "object"
assert set(result["properties"]) == {"id", "enabled", "schedule"}
assert result["required"] == ["id"]
assert "$defs" in result
def test_flattens_nested_combinator_branch_from_definitions(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
schema = {
"type": "object",
"oneOf": [
{"$ref": "#/definitions/Toggle"},
{"allOf": [{"properties": {"schedule": {"type": "string"}}, "required": ["schedule"]}]},
],
"definitions": {"Toggle": {"properties": {"enabled": {"type": "boolean"}}, "required": ["enabled"]}},
}
result = flatten_top_level_schema_combinators(schema)
assert "oneOf" not in result
assert set(result["properties"]) == {"enabled", "schedule"}
assert "required" not in result
def test_unresolvable_ref_branch_leaves_schema_untouched(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
schema = {
"type": "object",
"anyOf": [{"$ref": "https://example.com/schemas/automation.json"}],
"properties": {"id": {"type": "string"}},
}
assert flatten_top_level_schema_combinators(schema) is schema
def test_self_referencing_ref_branch_leaves_schema_untouched(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
schema = {
"type": "object",
"anyOf": [{"$ref": "#/$defs/Node"}],
"$defs": {"Node": {"type": "object", "anyOf": [{"$ref": "#/$defs/Node"}]}},
}
assert flatten_top_level_schema_combinators(schema) is schema
@pytest.mark.parametrize("boolean_branch", [True, False])
def test_boolean_branch_leaves_schema_untouched(self, boolean_branch):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
schema = {
"type": "object",
"anyOf": [boolean_branch, {"properties": {"id": {"type": "string"}}, "required": ["id"]}],
}
assert flatten_top_level_schema_combinators(schema) is schema
def test_root_required_is_combined_with_branch_required(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
allof_schema = {
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
"allOf": [{"properties": {"enabled": {"type": "boolean"}}, "required": ["enabled"]}],
}
anyof_schema = {
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
"anyOf": [
{"properties": {"name": {"type": "string"}, "a": {"type": "string"}}, "required": ["name", "a"]},
{"properties": {"name": {"type": "string"}, "b": {"type": "string"}}, "required": ["name", "b"]},
],
}
assert flatten_top_level_schema_combinators(allof_schema)["required"] == ["enabled", "id"]
assert flatten_top_level_schema_combinators(anyof_schema)["required"] == ["id", "name"]
def test_repeated_refs_are_expanded_once(self):
import time
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
fan_out, chain_length = 8, 8
schema = {
"type": "object",
"anyOf": [{"$ref": "#/$defs/Level0"}],
"$defs": {
**{
f"Level{level}": {"anyOf": [{"$ref": f"#/$defs/Level{level + 1}"}] * fan_out}
for level in range(chain_length)
},
f"Level{chain_length}": {"type": "object", "properties": {"id": {"type": "string"}}},
},
}
started = time.perf_counter()
result = flatten_top_level_schema_combinators(schema)
assert time.perf_counter() - started < 5
assert "anyOf" not in result
assert result["properties"] == {"id": {"type": "string"}}
def test_nesting_past_the_depth_cap_leaves_schema_untouched(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
def nested(levels):
leaf = {"type": "object", "properties": {"id": {"type": "string"}}}
return functools.reduce(lambda inner, _: {"type": "object", "anyOf": [inner]}, range(levels), leaf)
shallow, deep = nested(20), nested(40)
assert "anyOf" not in flatten_top_level_schema_combinators(shallow)
assert flatten_top_level_schema_combinators(deep) is deep
@pytest.mark.parametrize(
"branches",
[
[{"required": ["enabled"]}, {"required": ["schedule"]}],
[{"type": "object", "required": ["enabled"]}, {"type": "object", "required": ["schedule"]}],
],
)
def test_typeless_root_with_properties_flattens_branches_without_properties(self, branches):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
schema = {
"properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}, "schedule": {"type": "string"}},
"required": ["id"],
"anyOf": branches,
}
result = flatten_top_level_schema_combinators(schema)
assert "anyOf" not in result
assert result["type"] == "object"
assert set(result["properties"]) == {"id", "enabled", "schedule"}
assert result["required"] == ["id"]
def test_typeless_root_flattens_typed_object_branches_without_properties(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
schema = {
"anyOf": [
{"type": "object", "properties": {"id": {"type": "string"}}},
{"type": "object", "required": ["id"]},
]
}
result = flatten_top_level_schema_combinators(schema)
assert "anyOf" not in result
assert result["type"] == "object"
assert result["properties"] == {"id": {"type": "string"}}
assert "required" not in result
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

@ -1,4 +1,5 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
@ -1626,3 +1627,192 @@ 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"]
@pytest.mark.parametrize(
"model",
[
"gpt-4o",
"gpt-4.1-mini",
"gpt-4-turbo",
"o1",
"o3-pro",
"o4-mini",
"openai/gpt-4o",
"ft:gpt-4o-2024-08-06:org::abc",
],
)
def test_openai_flattens_for_models_whose_validator_rejects_combinators(self, model):
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model=model,
input="hi",
response_api_optional_request_params={"tools": [self._flat_function_tool()]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert "anyOf" not in result["tools"][0]["parameters"]
@pytest.mark.parametrize(
"model", ["gpt-5", "gpt-5-nano", "gpt-5.4-mini", "gpt-5.4-codex", "gpt-5.5", "openai/gpt-5.2"]
)
def test_openai_keeps_combinators_for_models_that_accept_them(self, model):
tool = self._flat_function_tool()
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model=model,
input="hi",
response_api_optional_request_params={"tools": [tool]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert result["tools"][0] is tool
def test_openai_leaves_non_dict_tool_entries_alone(self):
opaque_tool = SimpleNamespace(type="function", name="automation_update")
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-4o",
input="hi",
response_api_optional_request_params={"tools": [opaque_tool, self._flat_function_tool()]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert result["tools"][0] is opaque_tool
assert "anyOf" not in result["tools"][1]["parameters"]