From 9e7c25d95161300de8a44f3065865d12b2311a22 Mon Sep 17 00:00:00 2001 From: Kalai <101443484+likalight@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:09:41 +0800 Subject: [PATCH] fix: keep content: null on an assistant tool-call turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cleanup_none_field_in_message` dropped every key whose value was None, so an assistant message carrying `tool_calls` lost its `content` key entirely. That is the shape the OpenAI spec prescribes for a tool-call-only turn, and providers that require the key to be present reject the request. The helper's purpose, per its own docstring, is removing stray keys like `{"function": None}` that trip provider validation — not `content`. Exempt `content` when the assistant message carries `tool_calls` or a legacy `function_call`; everything else still gets stripped, including a None `content` on an assistant message with neither. Reproduces with a plain `litellm.completion()` call, no proxy and no credentials, via the intercept in the issue. Fixes #37711 Co-Authored-By: Claude Opus 5 (1M context) --- litellm/utils.py | 10 ++- .../test_cleanup_none_field_in_message.py | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/test_cleanup_none_field_in_message.py diff --git a/litellm/utils.py b/litellm/utils.py index e5ce7157e77..3e17f57f7d5 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7702,9 +7702,17 @@ 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 + + `content` is exempt on an assistant tool-call turn. The OpenAI spec + prescribes `content: null` for an assistant message that only calls tools, + and providers that require the key to be present reject the request when it + is dropped entirely. """ new_message: Final = message.copy() - return {k: v for k, v in new_message.items() if v is not None} + keeps_null_content: Final = message.get("role") == "assistant" and bool( + message.get("tool_calls") or message.get("function_call") + ) + return {k: v for k, v in new_message.items() if v is not None or (k == "content" and keeps_null_content)} def validate_chat_completion_user_messages(messages: list[AllMessageValues]): diff --git a/tests/test_litellm/test_cleanup_none_field_in_message.py b/tests/test_litellm/test_cleanup_none_field_in_message.py new file mode 100644 index 00000000000..424c39e0024 --- /dev/null +++ b/tests/test_litellm/test_cleanup_none_field_in_message.py @@ -0,0 +1,68 @@ +"""`content: null` is the OpenAI-prescribed shape for an assistant tool-call turn.""" + +from litellm.utils import cleanup_none_field_in_message, validate_and_fix_openai_messages + +TOOL_CALLS = [{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}] + + +class TestCleanupNoneFieldInMessage: + def test_keeps_null_content_on_a_tool_call_turn(self): + cleaned = cleanup_none_field_in_message( + message={"role": "assistant", "content": None, "tool_calls": TOOL_CALLS} + ) + + assert "content" in cleaned + assert cleaned["content"] is None + + def test_keeps_null_content_on_a_legacy_function_call_turn(self): + cleaned = cleanup_none_field_in_message( + message={ + "role": "assistant", + "content": None, + "function_call": {"name": "f", "arguments": "{}"}, + } + ) + + assert "content" in cleaned + + def test_still_strips_other_none_fields_on_that_turn(self): + # The original purpose of the helper: providers reject e.g. {"function": None}. + cleaned = cleanup_none_field_in_message( + message={ + "role": "assistant", + "content": None, + "tool_calls": TOOL_CALLS, + "function": None, + "name": None, + } + ) + + assert "function" not in cleaned + assert "name" not in cleaned + assert "content" in cleaned + + def test_still_strips_none_content_without_tool_calls(self): + cleaned = cleanup_none_field_in_message(message={"role": "assistant", "content": None}) + + assert "content" not in cleaned + + def test_user_message_is_unaffected(self): + cleaned = cleanup_none_field_in_message(message={"role": "user", "content": None, "name": None}) + + assert cleaned == {"role": "user"} + + +class TestValidateAndFixOpenAIMessages: + def test_tool_call_turn_survives_the_full_validation_pass(self): + """End to end through the caller that strips the field.""" + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "tool_calls": TOOL_CALLS}, + {"role": "tool", "tool_call_id": "call_1", "content": "ok"}, + ] + + fixed = validate_and_fix_openai_messages(messages=messages) + + assert "content" in fixed[1] + assert fixed[1]["content"] is None + assert fixed[1]["tool_calls"] == TOOL_CALLS