diff --git a/litellm/llms/zai/chat/transformation.py b/litellm/llms/zai/chat/transformation.py index c932dcd2e03..d8a2f4523fc 100644 --- a/litellm/llms/zai/chat/transformation.py +++ b/litellm/llms/zai/chat/transformation.py @@ -1,5 +1,8 @@ -from typing import List, Optional, Tuple +from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, overload +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + convert_content_list_to_str, +) from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam @@ -20,6 +23,50 @@ class ZAIChatConfig(OpenAIGPTConfig): dynamic_api_key = api_key or get_secret_str("ZAI_API_KEY") return api_base, dynamic_api_key + @overload + def _transform_messages( + self, messages: List[AllMessageValues], model: str, is_async: Literal[True] + ) -> Coroutine[Any, Any, List[AllMessageValues]]: ... + + @overload + def _transform_messages( + self, + messages: List[AllMessageValues], + model: str, + is_async: Literal[False] = False, + ) -> List[AllMessageValues]: ... + + def _transform_messages( + self, messages: List[AllMessageValues], model: str, is_async: bool = False + ) -> Union[List[AllMessageValues], Coroutine[Any, Any, List[AllMessageValues]]]: + """Flatten list-format content in tool/assistant messages for GLM. + + GLM's Jinja template checks ``m.content is string`` — list-format + content parts (used by Go clients like openai-go) are silently + dropped. Flatten them to strings before forwarding. + + Only tool/assistant roles are flattened — user messages are left + intact so the parent's image_url processing can handle them. + + See: https://github.com/BerriAI/litellm/issues/25868 + """ + for message in messages: + role = message.get("role") + if role in ("tool", "assistant"): + content = message.get("content") + if content is not None and not isinstance(content, str): + text = convert_content_list_to_str(message) + message["content"] = text if text else "" + + if is_async: + return super()._transform_messages( + messages=messages, model=model, is_async=True + ) + else: + return super()._transform_messages( + messages=messages, model=model, is_async=False + ) + def remove_cache_control_flag_from_messages_and_tools( self, model: str, diff --git a/tests/litellm/llms/zai/__init__.py b/tests/litellm/llms/zai/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/litellm/llms/zai/chat/__init__.py b/tests/litellm/llms/zai/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/litellm/llms/zai/chat/test_zai_chat_transformation.py b/tests/litellm/llms/zai/chat/test_zai_chat_transformation.py new file mode 100644 index 00000000000..837851f5bc1 --- /dev/null +++ b/tests/litellm/llms/zai/chat/test_zai_chat_transformation.py @@ -0,0 +1,157 @@ +""" +Unit tests for ZAI/GLM chat transformation. + +Tests that list-format content in tool/assistant messages is flattened +to strings before sending to GLM, which requires string-type content. + +See: https://github.com/BerriAI/litellm/issues/25868 +""" + +import pytest + +from litellm.llms.zai.chat.transformation import ZAIChatConfig + + +class TestZAITransformMessages: + """Test that ZAIChatConfig._transform_messages flattens tool/assistant content.""" + + def setup_method(self): + self.config = ZAIChatConfig() + + def test_tool_message_list_content_flattened(self): + """Tool message with list content is flattened to string.""" + messages = [ + {"role": "user", "content": "What is 1+1?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "calc", "arguments": '{"x": 1}'}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [{"type": "text", "text": "2"}], + }, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + tool_msg = [m for m in result if m.get("role") == "tool"][0] + assert isinstance(tool_msg["content"], str) + assert tool_msg["content"] == "2" + + def test_tool_message_string_content_unchanged(self): + """Tool message with string content passes through.""" + messages = [ + {"role": "tool", "tool_call_id": "call_1", "content": "result text"}, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert result[0]["content"] == "result text" + + def test_assistant_message_list_content_flattened(self): + """Assistant message with list content is flattened.""" + messages = [ + { + "role": "assistant", + "content": [{"type": "text", "text": "Hello there"}], + }, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert isinstance(result[0]["content"], str) + assert result[0]["content"] == "Hello there" + + def test_user_message_not_modified(self): + """User messages are not modified by the ZAI transform. + + User messages may contain image_url content parts that the parent + class processes — we must not flatten those. + """ + messages = [ + { + "role": "user", + "content": [{"type": "text", "text": "hello"}], + }, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert isinstance(result[0]["content"], list) + + def test_system_message_not_modified(self): + """System messages are not modified.""" + messages = [ + {"role": "system", "content": "You are helpful."}, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert result[0]["content"] == "You are helpful." + + def test_tool_message_none_content_unchanged(self): + """Tool message with None content stays None.""" + messages = [ + {"role": "tool", "tool_call_id": "call_1", "content": None}, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert result[0]["content"] is None + + def test_multiple_tool_messages_all_flattened(self): + """Multiple tool messages with list content are all flattened.""" + messages = [ + { + "role": "tool", + "tool_call_id": "call_1", + "content": [{"type": "text", "text": "result 1"}], + }, + { + "role": "tool", + "tool_call_id": "call_2", + "content": [{"type": "text", "text": "result 2"}], + }, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert all(isinstance(m["content"], str) for m in result) + assert result[0]["content"] == "result 1" + assert result[1]["content"] == "result 2" + + def test_tool_message_empty_list_becomes_empty_string(self): + """Tool message with empty list content becomes empty string.""" + messages = [ + {"role": "tool", "tool_call_id": "call_1", "content": []}, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert result[0]["content"] == "" + + def test_non_text_content_parts_dropped(self): + """Non-text content parts (e.g., image_url) in tool messages are dropped.""" + messages = [ + { + "role": "tool", + "tool_call_id": "call_1", + "content": [ + {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}, + {"type": "text", "text": "caption"}, + ], + }, + ] + + result = self.config._transform_messages(messages, model="glm-4.6") + + assert isinstance(result[0]["content"], str) + assert "caption" in result[0]["content"]