mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
fix(openai): flatten top-level tool schema combinators on chat completions
This commit is contained in:
parent
9448293903
commit
855f56fa94
2 changed files with 190 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue