fix: preserve content:null on assistant tool-call turns

cleanup_none_field_in_message() strips every None-valued key, including
the meaningful content:null that the OpenAI spec prescribes for an
assistant tool-call-only turn. Dropping the key breaks providers whose
deserializers require content to be present, surfacing as an upstream
BadRequestError (see #37711). Preserve content verbatim when the message
is an assistant turn carrying tool_calls; other None fields are still
stripped, and content:null without tool_calls keeps the old behavior.

Adds unit + end-to-end regression tests.
This commit is contained in:
linhongyu510 2026-08-25 17:48:51 +08:00
parent 4d7144160a
commit 67073348fd
2 changed files with 95 additions and 1 deletions

View file

@ -7875,9 +7875,22 @@ def cleanup_none_field_in_message(message: AllMessageValues):
Cleans up the message by removing the none field.
remove None fields in the message - e.g. {"function": None} - some providers raise validation errors
Exception: an assistant message that carries `tool_calls` may legitimately set
`content: null` (the shape the OpenAI spec prescribes for a tool-call-only
turn). Dropping the key entirely breaks providers whose deserializers require
it to be present, so `content` is preserved verbatim in that case.
"""
new_message: Final = message.copy()
return {k: v for k, v in new_message.items() if v is not None}
preserve_null_content: Final = (
new_message.get("role") == "assistant"
and new_message.get("content", "not-null") is None
and bool(new_message.get("tool_calls"))
)
cleaned = {k: v for k, v in new_message.items() if v is not None}
if preserve_null_content:
return {**cleaned, "content": None}
return cleaned
def validate_chat_completion_user_messages(messages: list[AllMessageValues]):

View file

@ -5689,3 +5689,84 @@ class TestDefaultReasoningEffortHydration:
model_info = dict(_get_model_info_helper(model="gpt-5.6-terra", custom_llm_provider="openai"))
assert model_info.get("default_reasoning_effort") is None
class TestCleanupNoneFieldInMessage:
"""`content: null` on an assistant tool-call turn is the shape the OpenAI
spec prescribes, and strict provider deserializers reject the request when
the key is dropped entirely (see #37711). It must survive message cleanup,
while genuinely irrelevant None fields are still stripped.
"""
def test_null_content_preserved_on_assistant_tool_call(self):
from litellm.utils import cleanup_none_field_in_message
message = {
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
],
}
cleaned = cleanup_none_field_in_message(message)
assert "content" in cleaned
assert cleaned["content"] is None
assert cleaned["tool_calls"] == message["tool_calls"]
def test_null_content_still_dropped_without_tool_calls(self):
from litellm.utils import cleanup_none_field_in_message
cleaned = cleanup_none_field_in_message({"role": "assistant", "content": None})
assert "content" not in cleaned
def test_other_none_fields_still_stripped(self):
from litellm.utils import cleanup_none_field_in_message
message = {
"role": "assistant",
"content": None,
"function_call": None,
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
],
}
cleaned = cleanup_none_field_in_message(message)
assert "content" in cleaned
assert "function_call" not in cleaned
def test_end_to_end_null_content_reaches_openai_sdk(self, monkeypatch):
import litellm
from openai.resources.chat.completions import Completions
captured = {}
def spy(self, *args, **kwargs):
captured["messages"] = kwargs.get("messages")
raise RuntimeError("stop")
monkeypatch.setattr(Completions, "create", spy)
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
]
with pytest.raises(litellm.InternalServerError, match="stop"):
litellm.completion(
model="openai/some-model",
messages=messages,
max_tokens=1,
api_base="http://127.0.0.1:1/v1",
api_key="x",
timeout=3,
)
assert captured.get("messages") is not None
assistant_msg = captured["messages"][1]
assert "content" in assistant_msg
assert assistant_msg["content"] is None