diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index f0e5086b660..1c8f10d3307 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -10,6 +10,7 @@ from collections.abc import Iterable, Mapping, Sequence from itertools import groupby from os import PathLike from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, cast from openai.types.chat.chat_completion_custom_tool_param import ( @@ -1089,6 +1090,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 diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 2fa44cfc2e3..27e4b7dc4a6 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, cast, get_type_hints import httpx @@ -29,6 +31,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 diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index b15a16ffc23..37e940460f6 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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. diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index aec6d12069f..772fbf98c57 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -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 diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index e314b94444b..1a90db7c1fe 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -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"]