diff --git a/litellm/llms/zai/chat/transformation.py b/litellm/llms/zai/chat/transformation.py index c932dcd2e03..f8f8029e97d 100644 --- a/litellm/llms/zai/chat/transformation.py +++ b/litellm/llms/zai/chat/transformation.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import Any, Coroutine, List, Optional, Tuple, Union from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam @@ -8,6 +8,30 @@ from ...openai.chat.gpt_transformation import OpenAIGPTConfig ZAI_API_BASE = "https://api.z.ai/api/paas/v4" +def _flatten_content_parts(content: Any) -> Any: + """Flatten OpenAI multi-part content to a plain string. + + The OpenAI spec allows tool/assistant message content as either a plain + string or a list of content parts (e.g. [{"type": "text", "text": "..."}]). + GLM's chat template checks ``m.content is string`` and silently drops + list-format content (same root cause as vllm-project/vllm#39614). + This helper normalises both forms to a plain string. + """ + if isinstance(content, str) or content is None: + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict): + text = part.get("text") + if text: + parts.append(text) + elif isinstance(part, str): + parts.append(part) + return "\n".join(parts) if parts else "" + return content + + class ZAIChatConfig(OpenAIGPTConfig): @property def custom_llm_provider(self) -> Optional[str]: @@ -56,3 +80,21 @@ class ZAIChatConfig(OpenAIGPTConfig): pass return base_params + + 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 and assistant messages before sending to ZAI. + + GLM's chat template checks ``m.content is string`` and silently drops list-format + content (e.g. [{"type": "text", "text": "..."}]). This ensures tool results and + assistant messages always reach the model as plain strings. + + Issue: https://github.com/BerriAI/litellm/issues/25868 + """ + for message in messages: + role = message.get("role") + content = message.get("content") + if role in ("tool", "assistant") and isinstance(content, list): + message["content"] = _flatten_content_parts(content) # type: ignore + return super()._transform_messages(messages=messages, model=model, is_async=is_async) # type: ignore diff --git a/tests/test_litellm/llms/zai/test_zai_provider.py b/tests/test_litellm/llms/zai/test_zai_provider.py index d1e4359d048..9ef2414a785 100644 --- a/tests/test_litellm/llms/zai/test_zai_provider.py +++ b/tests/test_litellm/llms/zai/test_zai_provider.py @@ -4,6 +4,7 @@ Tests for Z.AI (Zhipu AI) provider - GLM models import json import math +from typing import cast import pytest import respx @@ -179,3 +180,116 @@ def test_zai_sync_completion(respx_mock, zai_response, monkeypatch): assert response.choices[0].message.content == "Hello! How can I help you today?" assert response.usage.total_tokens == 25 + + +class TestZAIMessageTransformation: + """Tests for ZAI message content flattening. + + Issue: https://github.com/BerriAI/litellm/issues/25868 + GLM's Jinja chat template checks ``m.content is string`` and silently drops + list-format content. ZAIChatConfig._transform_messages must flatten these + before forwarding to z.ai. + """ + + def test_flatten_tool_message_content_list(self): + """Tool message with list-format content is flattened to a plain string.""" + from litellm.llms.zai.chat.transformation import ZAIChatConfig + + config = ZAIChatConfig() + messages = cast( + list, + [ + {"role": "user", "content": "What is the temperature in Tokyo?"}, + { + "role": "assistant", + "content": "Let me check.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_temp", "arguments": '{"city": "Tokyo"}'}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [{"type": "text", "text": "22.5\u00b0C, partly cloudy."}], + }, + ], + ) + + result = config._transform_messages(messages=messages, model="glm-5.1") + + tool_msg = result[2] + assert isinstance(tool_msg["content"], str), ( + f"Expected str content, got {type(tool_msg['content'])}" + ) + assert tool_msg["content"] == "22.5\u00b0C, partly cloudy." + + def test_flatten_assistant_message_content_list(self): + """Assistant message with list-format content is flattened to a plain string.""" + from litellm.llms.zai.chat.transformation import ZAIChatConfig + + config = ZAIChatConfig() + messages = cast( + list, + [ + { + "role": "assistant", + "content": [{"type": "text", "text": "Let me think about this."}], + }, + ], + ) + + result = config._transform_messages(messages=messages, model="glm-5.1") + + assert result[0]["content"] == "Let me think about this." + + def test_string_content_passes_through_unchanged(self): + """String content is not modified by the flattening step.""" + from litellm.llms.zai.chat.transformation import ZAIChatConfig + + config = ZAIChatConfig() + messages = cast( + list, + [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "tool", "tool_call_id": "c1", "content": "Result data"}, + ], + ) + + result = config._transform_messages(messages=messages, model="glm-5.1") + + assert result[0]["content"] == "Hello" + assert result[1]["content"] == "Hi there!" + assert result[2]["content"] == "Result data" + + def test_flatten_content_parts_helper_multipart(self): + """Multiple text parts are joined with newline.""" + from litellm.llms.zai.chat.transformation import _flatten_content_parts + + content = [ + {"type": "text", "text": "Line 1"}, + {"type": "text", "text": "Line 2"}, + ] + assert _flatten_content_parts(content) == "Line 1\nLine 2" + + def test_flatten_content_parts_helper_empty_list(self): + """Empty list returns empty string.""" + from litellm.llms.zai.chat.transformation import _flatten_content_parts + + assert _flatten_content_parts([]) == "" + + def test_flatten_content_parts_helper_string_passthrough(self): + """Plain string passes through unchanged.""" + from litellm.llms.zai.chat.transformation import _flatten_content_parts + + assert _flatten_content_parts("already a string") == "already a string" + + def test_flatten_content_parts_helper_none(self): + """None passes through unchanged.""" + from litellm.llms.zai.chat.transformation import _flatten_content_parts + + assert _flatten_content_parts(None) is None