From 622ca30b9a3559eba5ad3bc9d0d06ce5ee93f382 Mon Sep 17 00:00:00 2001 From: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:07:22 -0700 Subject: [PATCH] fix(utils): avoid pydantic serializer warnings in validate_and_fix_openai_messages Copy pydantic Messages (via model_dump) and dicts before rewriting tool_calls, so the later model_dump() no longer warns and the caller's messages are left untouched. Fixes #25880 Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> --- litellm/utils.py | 7 ++ .../test_validate_and_fix_openai_messages.py | 82 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/unit/test_validate_and_fix_openai_messages.py diff --git a/litellm/utils.py b/litellm/utils.py index fc3acd3065d..bf1c20e6286 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8427,6 +8427,13 @@ def validate_and_fix_openai_messages(messages: list): """ new_messages: Final = [] for message in messages: + # Copy before mutating: assigning dicts into a pydantic Message's + # tool_calls triggers PydanticSerializationUnexpectedValue warnings on + # the later model_dump(), and it rewrites the caller's objects. + if isinstance(message, BaseModel): + message = message.model_dump(exclude_none=True) + elif isinstance(message, dict): + message = message.copy() if not message.get("role"): message["role"] = "assistant" if message.get("tool_calls"): diff --git a/tests/unit/test_validate_and_fix_openai_messages.py b/tests/unit/test_validate_and_fix_openai_messages.py new file mode 100644 index 00000000000..cd2d41bd8f2 --- /dev/null +++ b/tests/unit/test_validate_and_fix_openai_messages.py @@ -0,0 +1,82 @@ +"""Tests for validate_and_fix_openai_messages (issue #25880).""" + +import warnings + +from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message +from litellm.utils import validate_and_fix_openai_messages + + +def _make_tool_call_message() -> Message: + tc = ChatCompletionMessageToolCall( + id="toolu_1", + type="function", + function=Function(name="add", arguments='{"a":1,"b":2}'), + ) + return Message(content="", role="assistant", tool_calls=[tc]) + + +def test_no_pydantic_serialization_warning_with_tool_calls(): + msg = _make_tool_call_message() + + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + message=".*serialized value may not be as expected.*", + category=UserWarning, + ) + result = validate_and_fix_openai_messages( + [ + {"role": "user", "content": "hi"}, + msg, + {"role": "tool", "tool_call_id": "toolu_1", "content": "3"}, + ] + ) + + assert len(result) == 3 + assert all(isinstance(m, dict) for m in result) + assistant_msg = result[1] + assert assistant_msg["role"] == "assistant" + assert isinstance(assistant_msg["tool_calls"][0], dict) + assert assistant_msg["tool_calls"][0]["function"]["name"] == "add" + + +def test_original_message_not_mutated(): + msg = _make_tool_call_message() + original_tc = msg.tool_calls[0] + + validate_and_fix_openai_messages([{"role": "user", "content": "hi"}, msg]) + + assert isinstance(msg.tool_calls[0], ChatCompletionMessageToolCall) + assert msg.tool_calls[0] is original_tc + + +def test_dict_messages_with_tool_calls_still_work(): + messages = [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "greet", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "hi"}, + ] + + result = validate_and_fix_openai_messages(messages) + + assert len(result) == 3 + assert result[1]["tool_calls"][0]["function"]["name"] == "greet" + + +def test_missing_role_defaults_to_assistant_without_mutating_input(): + original = {"content": "some content"} + + result = validate_and_fix_openai_messages([original]) + + assert result[0]["role"] == "assistant" + assert original == {"content": "some content"}