fix(openai): flatten top-level tool schema combinators on chat completions

This commit is contained in:
mateo-berri 2026-08-29 16:25:28 -07:00
parent 9448293903
commit 855f56fa94
2 changed files with 190 additions and 10 deletions

View file

@ -4,7 +4,8 @@ Support for gpt model family
import json
import os
from collections.abc import AsyncIterator, Coroutine, Iterator
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast, overload
from urllib.parse import urlparse
@ -19,6 +20,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_tool_reference_parts_from_tool_messages,
flatten_top_level_schema_combinators,
get_tool_call_names,
hoist_images_from_tool_messages,
)
@ -65,6 +67,22 @@ else:
LiteLLMLoggingObj = Any
_NO_TOOLS_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
def _tool_with_flattened_parameters(tool: Mapping[str, object]) -> Mapping[str, object]:
function: Final = tool.get("function")
if not isinstance(function, dict):
return tool
parameters: Final = function.get("parameters")
if not isinstance(parameters, dict):
return tool
flattened: Final = flatten_top_level_schema_combinators(parameters)
if flattened is parameters:
return tool
return {**tool, "function": {**function, "parameters": flattened}} # mutable-ok: request tools are JSON dicts
class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
"""
Reference: https://platform.openai.com/docs/api-reference/chat/create
@ -393,6 +411,26 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
)
return messages, tools
def _targets_openai_hosted_endpoint(
self,
custom_llm_provider: str | None,
api_base: str | None,
) -> bool:
"""
True only for the generic `openai` provider actually pointed at
api.openai.com (no custom api_base, or an openai.com host): the one
backend enforcing OpenAI-only request strictness.
"""
if custom_llm_provider != "openai":
return False
resolved_api_base = api_base or litellm.api_base or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")
if not resolved_api_base:
return True
hostname: Final = urlparse(resolved_api_base).hostname
if hostname is None:
return True
return hostname == "openai.com" or hostname.endswith(".openai.com")
def _should_preserve_cache_control_for_endpoint(
self,
custom_llm_provider: str | None,
@ -404,15 +442,37 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
api_base. Those can understand cache_control, so it must survive there.
Real OpenAI cannot, so it is still stripped for an openai.com host.
"""
if custom_llm_provider != "openai":
return False
resolved_api_base = api_base or litellm.api_base or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")
if not resolved_api_base:
return False
hostname: Final = urlparse(resolved_api_base).hostname
if hostname is None:
return False
return hostname != "openai.com" and not hostname.endswith(".openai.com")
return custom_llm_provider == "openai" and not self._targets_openai_hosted_endpoint(
custom_llm_provider, api_base
)
def _flattened_tools_update_for_openai(
self,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
) -> Mapping[str, object]:
"""
OpenAI's chat completions validator rejects tool `parameters` carrying
'oneOf'/'anyOf'/'allOf'/'enum'/'const'/'not' at the top level for every
model family (unlike the Responses API, where GPT-5+ accepts them), so
tool schemas bound for api.openai.com get their top-level combinators
flattened; OpenAI-compatible backends on a custom api_base accept the
caller's schema as-is and keep it.
"""
tools: Final = optional_params.get("tools")
if not isinstance(tools, list):
return _NO_TOOLS_UPDATE
provider: Final = litellm_params.get("custom_llm_provider")
raw_api_base: Final = litellm_params.get("api_base")
if not self._targets_openai_hosted_endpoint(
provider if isinstance(provider, str) else None,
raw_api_base if isinstance(raw_api_base, str) else None,
):
return _NO_TOOLS_UPDATE
flattened: Final = [ # mutable-ok: request tools are a JSON list
_tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools
]
return MappingProxyType({"tools": flattened})
def transform_request(
self,
@ -444,6 +504,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
"model": model,
"messages": messages,
**optional_params,
**self._flattened_tools_update_for_openai(optional_params, litellm_params),
}
async def async_transform_request(
@ -473,6 +534,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
"model": model,
"messages": transformed_messages,
**optional_params,
**self._flattened_tools_update_for_openai(optional_params, litellm_params),
}
else:
## allow for any object specific behaviour to be handled

View file

@ -975,3 +975,121 @@ class TestOpenAIPromptCacheBreakpointChatPath:
assert request["messages"][1]["content"] == [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]
assert request["extra_body"] == {"prompt_cache_options": self.EXPLICIT}
assert "prompt_cache_options" not in request
class TestToolSchemaCombinatorFlatteningForOpenAI:
"""
Regression tests for LIT-6488: OpenAI's chat completions validator rejects
tool parameters carrying a top-level anyOf/oneOf/allOf for every model
family (GPT-5 included, unlike the Responses API), so requests bound for
api.openai.com get those combinators flattened into one object schema,
while OpenAI-compatible backends on a custom api_base and other providers
keep the caller's schema untouched.
"""
def setup_method(self):
self.config = OpenAIGPTConfig()
@pytest.fixture(autouse=True)
def _clean_openai_base_env(self, monkeypatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setattr(litellm, "api_base", None, raising=False)
@staticmethod
def _anyof_tool():
return {
"type": "function",
"function": {
"name": "automation_update",
"description": "Update an automation",
"parameters": {
"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 _transform(self, config, model, litellm_params, tools):
return config.transform_request(
model=model,
messages=[{"role": "user", "content": "hi"}],
optional_params={"tools": tools},
litellm_params=litellm_params,
headers={},
)
def test_flattens_top_level_anyof_for_hosted_openai(self):
request = self._transform(
self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [self._anyof_tool()]
)
parameters = request["tools"][0]["function"]["parameters"]
assert "anyOf" not in parameters
assert parameters["type"] == "object"
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}
assert parameters["required"] == ["id"]
assert request["tools"][0]["function"]["name"] == "automation_update"
def test_gpt5_family_flattens_on_chat_completions(self):
request = self._transform(
OpenAIGPT5Config(), "gpt-5.6", {"custom_llm_provider": "openai", "api_base": None}, [self._anyof_tool()]
)
assert "anyOf" not in request["tools"][0]["function"]["parameters"]
def test_custom_api_base_keeps_union(self):
tool = self._anyof_tool()
request = self._transform(
self.config,
"gpt-4o",
{"custom_llm_provider": "openai", "api_base": "http://localhost:8000/v1"},
[tool],
)
assert request["tools"][0]["function"]["parameters"] == self._anyof_tool()["function"]["parameters"]
def test_non_openai_provider_keeps_union(self):
request = self._transform(
self.config, "some-oss-model", {"custom_llm_provider": "groq", "api_base": None}, [self._anyof_tool()]
)
assert request["tools"][0]["function"]["parameters"] == self._anyof_tool()["function"]["parameters"]
def test_caller_tool_dict_is_not_mutated(self):
tool = self._anyof_tool()
self._transform(self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool])
assert tool == self._anyof_tool()
def test_clean_object_schema_passes_through_as_same_object(self):
tool = {
"type": "function",
"function": {
"name": "lookup",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]},
},
}
request = self._transform(
self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool]
)
assert request["tools"][0] is tool
@pytest.mark.asyncio
async def test_async_transform_request_flattens_for_hosted_openai(self):
request = await self.config.async_transform_request(
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
optional_params={"tools": [self._anyof_tool()]},
litellm_params={"custom_llm_provider": "openai", "api_base": None},
headers={},
)
parameters = request["tools"][0]["function"]["parameters"]
assert "anyOf" not in parameters
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}