fix(zai): flatten list-format content in tool/assistant messages for GLM

GLM's Jinja chat template checks `m.content is string` — when it
receives list-format content parts (as sent by Go clients like
openai-go), the content is silently replaced with an empty artifact.

Flatten list-format content to strings for tool/assistant messages
using the existing convert_content_list_to_str utility. User messages
are left intact so the parent's image_url processing still works.

Fixes #25868

Signed-off-by: Jay <moonandstar99@yahoo.com>
This commit is contained in:
Jay 2026-05-08 02:34:03 -04:00
parent 98cd057f38
commit a177e05c82
4 changed files with 205 additions and 1 deletions

View file

@ -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,

View file

View file

View file

@ -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"]