mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
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:
parent
fa25ff2a2e
commit
9b8ad46f37
4 changed files with 416 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue