mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
commit
ecd42ea77a
5 changed files with 760 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,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue