mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(openai): drop tool_reference parts from tool messages at the chat boundary
OpenAI's chat completions API rejects tool_reference content parts in role tool messages, so a mixed text plus reference tool result carried through the Anthropic adapter turned a previously working request into a 400 on chat-routed OpenAI and Azure deployments. Strip the reference parts there, keeping a reference-only result as an empty-text tool message so the preceding tool_call stays answered, mirroring the Responses bridge skip.
This commit is contained in:
parent
e26ea0bc95
commit
edde2e50ef
6 changed files with 205 additions and 2 deletions
|
|
@ -1747,6 +1747,46 @@ def hoist_images_from_tool_messages(
|
|||
]
|
||||
|
||||
|
||||
def _is_tool_reference_part(part: object) -> bool:
|
||||
return isinstance(part, dict) and part.get("type") == "tool_reference"
|
||||
|
||||
|
||||
def _tool_message_carries_tool_reference(message: AllMessageValues) -> bool:
|
||||
if message.get("role") != "tool":
|
||||
return False
|
||||
content = message.get("content")
|
||||
return isinstance(content, list) and any(_is_tool_reference_part(part) for part in content)
|
||||
|
||||
|
||||
def _drop_tool_reference_parts(message: AllMessageValues) -> AllMessageValues:
|
||||
if not _tool_message_carries_tool_reference(message):
|
||||
return message
|
||||
content = cast(list, message.get("content")) # cast-ok: shape checked by _tool_message_carries_tool_reference
|
||||
remaining_parts = [ # mutable-ok: tool message content must stay a json list
|
||||
part for part in content if not _is_tool_reference_part(part)
|
||||
]
|
||||
new_content = remaining_parts if remaining_parts else ""
|
||||
rewritten = {**message, "content": new_content} # mutable-ok: chat messages are plain json dicts
|
||||
return cast(AllMessageValues, rewritten) # cast-ok: dict spread keeps keys like cache_control
|
||||
|
||||
|
||||
def drop_tool_reference_parts_from_tool_messages(
|
||||
messages: list[AllMessageValues], # mutable-ok: message pipelines type messages as mutable lists
|
||||
) -> list[AllMessageValues]: # mutable-ok: message pipelines type messages as mutable lists
|
||||
"""
|
||||
Remove tool_reference content parts from role:"tool" messages.
|
||||
|
||||
The OpenAI chat spec only accepts text in tool messages, so a tool_reference
|
||||
part carried through the Anthropic adapter makes strict providers reject the
|
||||
request. The reference names an already-declared tool rather than carrying
|
||||
content, so it is dropped; a reference-only result keeps its tool message with
|
||||
empty text so the preceding tool_call stays answered.
|
||||
"""
|
||||
if not any(_tool_message_carries_tool_reference(message) for message in messages):
|
||||
return messages
|
||||
return [_drop_tool_reference_parts(message) for message in messages] # mutable-ok: pipelines mutate message lists
|
||||
|
||||
|
||||
def _attempt_json_repair(s: str) -> Any | None:
|
||||
"""
|
||||
Attempt to repair truncated JSON produced by LLM tool calls.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from httpx._models import Headers, Response
|
|||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
drop_tool_reference_parts_from_tool_messages,
|
||||
hoist_images_from_tool_messages,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
|
|
@ -252,7 +253,8 @@ class AzureOpenAIConfig(BaseConfig):
|
|||
litellm_params: dict,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
azure_messages: Final = convert_to_azure_openai_messages(hoist_images_from_tool_messages(messages))
|
||||
stripped_messages: Final = drop_tool_reference_parts_from_tool_messages(messages)
|
||||
azure_messages: Final = convert_to_azure_openai_messages(hoist_images_from_tool_messages(stripped_messages))
|
||||
return {
|
||||
"model": model,
|
||||
"messages": azure_messages,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
|
|||
_should_convert_tool_call_to_json_mode,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
drop_tool_reference_parts_from_tool_messages,
|
||||
get_tool_call_names,
|
||||
hoist_images_from_tool_messages,
|
||||
)
|
||||
|
|
@ -336,7 +337,8 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
|
|||
self, messages: list[AllMessageValues], model: str, is_async: bool = False
|
||||
) -> list[AllMessageValues] | Coroutine[Any, Any, list[AllMessageValues]]:
|
||||
"""OpenAI no longer supports image_url as a string, so we need to convert it to a dict"""
|
||||
hoisted_messages: Final = hoist_images_from_tool_messages(messages)
|
||||
stripped_messages: Final = drop_tool_reference_parts_from_tool_messages(messages)
|
||||
hoisted_messages: Final = hoist_images_from_tool_messages(stripped_messages)
|
||||
|
||||
async def _async_transform():
|
||||
for message in hoisted_messages:
|
||||
|
|
|
|||
|
|
@ -1027,3 +1027,70 @@ def test_update_messages_xlitellm_decode_does_not_override_mapping():
|
|||
updated = update_messages_with_model_file_ids(messages, "model-A", mapping)
|
||||
|
||||
assert updated[0]["content"][0]["file"]["file_id"] == "provider-explicit-id"
|
||||
|
||||
|
||||
def test_drop_tool_reference_parts_keeps_text_parts():
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
drop_tool_reference_parts_from_tool_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
_assistant_tool_call_msg("call_1"),
|
||||
_tool_msg(
|
||||
[
|
||||
{"type": "text", "text": "WebFetch tool loaded successfully."},
|
||||
{"type": "tool_reference", "tool_name": "WebFetch"},
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
result = drop_tool_reference_parts_from_tool_messages(messages)
|
||||
|
||||
assert result[1]["content"] == [{"type": "text", "text": "WebFetch tool loaded successfully."}]
|
||||
assert result[1]["tool_call_id"] == "call_1"
|
||||
|
||||
|
||||
def test_drop_tool_reference_parts_reference_only_becomes_empty_text():
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
drop_tool_reference_parts_from_tool_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
_assistant_tool_call_msg("call_1"),
|
||||
_tool_msg([{"type": "tool_reference", "tool_name": "WebFetch"}]),
|
||||
]
|
||||
|
||||
result = drop_tool_reference_parts_from_tool_messages(messages)
|
||||
|
||||
assert result[1] == {"role": "tool", "tool_call_id": "call_1", "content": ""}
|
||||
|
||||
|
||||
def test_drop_tool_reference_parts_without_references_passes_through():
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
drop_tool_reference_parts_from_tool_messages,
|
||||
)
|
||||
|
||||
messages = [
|
||||
_assistant_tool_call_msg("call_1"),
|
||||
_tool_msg([{"type": "text", "text": "plain result"}]),
|
||||
]
|
||||
|
||||
assert drop_tool_reference_parts_from_tool_messages(messages) is messages
|
||||
|
||||
|
||||
def test_drop_tool_reference_parts_leaves_non_tool_messages_alone():
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
drop_tool_reference_parts_from_tool_messages,
|
||||
)
|
||||
|
||||
user_message = {"role": "user", "content": [{"type": "tool_reference", "tool_name": "WebFetch"}]}
|
||||
messages = [
|
||||
user_message,
|
||||
_assistant_tool_call_msg("call_1"),
|
||||
_tool_msg([{"type": "tool_reference", "tool_name": "WebFetch"}]),
|
||||
]
|
||||
|
||||
result = drop_tool_reference_parts_from_tool_messages(messages)
|
||||
|
||||
assert result[0] == user_message
|
||||
assert result[2]["content"] == ""
|
||||
|
|
|
|||
|
|
@ -102,6 +102,35 @@ def test_transform_request_hoists_tool_message_image():
|
|||
]
|
||||
|
||||
|
||||
def test_transform_request_drops_tool_reference_parts():
|
||||
"""Azure's transform_request shares the tool-message sanitizing with OpenAI:
|
||||
tool_reference parts are dropped, a reference-only result keeps its tool
|
||||
message with empty text (#37462 round trip)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "load the WebFetch tool"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "ToolSearch", "arguments": "{}"}}],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": [{"type": "tool_reference", "tool_name": "WebFetch"}],
|
||||
},
|
||||
]
|
||||
|
||||
request = AzureOpenAIConfig().transform_request(
|
||||
model="gpt-4o",
|
||||
messages=messages,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request["messages"][2]["content"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, emitted_key, absent_key",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -869,6 +869,69 @@ class TestToolMessageImageHoisting:
|
|||
assert result[3]["content"] == self.HOISTED_USER_CONTENT
|
||||
|
||||
|
||||
class TestToolReferenceStripping:
|
||||
"""transform_request drops tool_reference parts from tool messages: OpenAI's
|
||||
chat API rejects them, and the reference names an already-declared tool
|
||||
rather than carrying content (#37462 round trip)."""
|
||||
|
||||
def setup_method(self):
|
||||
self.config = OpenAIGPTConfig()
|
||||
|
||||
def _messages_with_tool_reference(self, extra_parts=()):
|
||||
return [
|
||||
{"role": "user", "content": "load the WebFetch tool"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "type": "function", "function": {"name": "ToolSearch", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": [*extra_parts, {"type": "tool_reference", "tool_name": "WebFetch"}],
|
||||
},
|
||||
]
|
||||
|
||||
def test_transform_request_keeps_text_and_drops_reference(self):
|
||||
request = self.config.transform_request(
|
||||
model="gpt-4.1",
|
||||
messages=self._messages_with_tool_reference(extra_parts=({"type": "text", "text": "loaded"},)),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
tool_message = request["messages"][2]
|
||||
assert tool_message["content"] == [{"type": "text", "text": "loaded"}]
|
||||
assert tool_message["tool_call_id"] == "call_1"
|
||||
|
||||
def test_transform_request_reference_only_keeps_tool_message_with_empty_text(self):
|
||||
request = self.config.transform_request(
|
||||
model="gpt-4.1",
|
||||
messages=self._messages_with_tool_reference(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert [m.get("role") for m in request["messages"]] == ["user", "assistant", "tool"]
|
||||
assert request["messages"][2]["content"] == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_transform_request_drops_reference(self):
|
||||
request = await self.config.async_transform_request(
|
||||
model="gpt-4.1",
|
||||
messages=self._messages_with_tool_reference(),
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request["messages"][2]["content"] == ""
|
||||
|
||||
|
||||
class TestOpenAIPromptCacheBreakpointChatPath:
|
||||
"""Chat-path shape for OpenAI explicit prompt caching (#37509)."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue