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

GLM's Jinja chat template checks ``m.content is string`` and silently
drops list-format content (e.g. [{"type": "text", "text": "..."}]),
causing tool results to be lost and the model to respond as if the tool
returned no data.

Add ZAIChatConfig._transform_messages() that normalises list-format
content in tool and assistant messages to plain strings before delegating
to the parent OpenAIGPTConfig transformer. User-facing content in user
messages is not affected.

Fixes #25868
This commit is contained in:
octo-patch 2026-04-18 09:14:52 +08:00
parent 850fe595ac
commit 3469bb0f1f
2 changed files with 157 additions and 1 deletions

View file

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

View file

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