From 67073348fd0cfd20e3d7a12902c8bc8926bdbc51 Mon Sep 17 00:00:00 2001 From: linhongyu510 Date: Tue, 25 Aug 2026 17:48:51 +0800 Subject: [PATCH] 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. --- litellm/utils.py | 15 +++++- tests/test_litellm/test_utils.py | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/litellm/utils.py b/litellm/utils.py index 5cd9bfc5f32..eedcea4d3e0 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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]): diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 1ff50bd0116..243fe983df9 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -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