diff --git a/litellm/utils.py b/litellm/utils.py index 7de7ce6188d..d65ef631b69 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8921,14 +8921,23 @@ def convert_list_message_to_dict( return [dict(cleanup_none_field_in_message(message=_as_message_value(message))) for message in messages] -def validate_and_fix_openai_messages(messages: list): +def validate_and_fix_openai_messages( + messages: list[Any], # mutable-ok: caller passes arbitrary message dicts +): """ Ensures all messages are valid OpenAI chat completion messages. Handles missing role for assistant messages. """ - new_messages: Final = [] + new_messages: Final[list[AllMessageValues]] = [] # mutable-ok: list is populated by appending, name never rebound 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"): @@ -8941,7 +8950,9 @@ def validate_and_fix_openai_messages(messages: list): ), ) cleaned_message = cleanup_none_field_in_message(message=convert_msg_to_dict) - new_messages.append(cleaned_message) + new_messages.append( + cast(AllMessageValues, cleaned_message) + ) # cast-ok: cleanup_none_field_in_message return satisfies AllMessageValues return validate_chat_completion_user_messages(messages=new_messages) 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"}