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:
mateo-berri 2026-08-26 23:43:41 -07:00
parent e26ea0bc95
commit edde2e50ef
6 changed files with 205 additions and 2 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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:

View file

@ -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"] == ""

View file

@ -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",
[

View file

@ -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)."""