diff --git a/python/sglang/srt/entrypoints/openai/serving_chat.py b/python/sglang/srt/entrypoints/openai/serving_chat.py index 627d116b6..6646983dc 100644 --- a/python/sglang/srt/entrypoints/openai/serving_chat.py +++ b/python/sglang/srt/entrypoints/openai/serving_chat.py @@ -60,6 +60,28 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def normalize_tool_content(role: str, content): + """Normalize tool message content from OpenAI array format to plain string. + + OpenAI clients may send tool content as a list of content parts + (e.g. [{"type":"text","text":"..."}]) but most chat templates expect + a plain string for tool messages. Only flatten when ALL items are + pure OpenAI text parts; preserve lists containing non-text-type items + that some templates intentionally iterate over. + """ + if role != "tool" or not isinstance(content, list): + return content + parts = content + is_openai_text_parts = all( + (isinstance(p, dict) and p.get("type") == "text") or isinstance(p, str) + for p in parts + ) + if is_openai_text_parts: + text_parts = [p.get("text", "") if isinstance(p, dict) else p for p in parts] + return " ".join(text_parts) + return content + + def _extract_max_dynamic_patch(request: ChatCompletionRequest): img_vals = [] vid_vals = [] @@ -457,6 +479,10 @@ class OpenAIServingChat(OpenAIServingBase): modalities, ) + processed_msg["content"] = normalize_tool_content( + processed_msg["role"], processed_msg.get("content") + ) + # per the Transformers docs & maintainers, tool call arguments in # assistant-role messages with tool_calls need to be dicts not JSON str - # this is how tool-use chat templates will expect them moving forwards diff --git a/test/registered/openai_server/basic/test_serving_chat.py b/test/registered/openai_server/basic/test_serving_chat.py index 8095420f9..6dd0cdea7 100644 --- a/test/registered/openai_server/basic/test_serving_chat.py +++ b/test/registered/openai_server/basic/test_serving_chat.py @@ -19,7 +19,10 @@ from sglang.srt.entrypoints.openai.protocol import ( ChatCompletionRequest, MessageProcessingResult, ) -from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat +from sglang.srt.entrypoints.openai.serving_chat import ( + OpenAIServingChat, + normalize_tool_content, +) from sglang.srt.managers.io_struct import GenerateReqInput from sglang.srt.utils import get_or_create_event_loop from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci @@ -982,5 +985,42 @@ class TestProcessToolCallsWithRequiredToolChoice(unittest.TestCase): self.assertIsNone(tool_calls) +class TestNormalizeToolContent(unittest.TestCase): + """Unit tests for normalize_tool_content().""" + + def test_openai_text_parts_flattened(self): + result = normalize_tool_content("tool", [{"type": "text", "text": "10525"}]) + self.assertEqual(result, "10525") + + def test_multiple_text_parts_joined(self): + result = normalize_tool_content( + "tool", + [{"type": "text", "text": "hello"}, {"type": "text", "text": "world"}], + ) + self.assertEqual(result, "hello world") + + def test_non_text_part_list_preserved(self): + content = [{"name": "func", "output": "result"}] + result = normalize_tool_content("tool", content) + self.assertIs(result, content) + + def test_string_content_unchanged(self): + self.assertEqual(normalize_tool_content("tool", "hello"), "hello") + + def test_empty_list_returns_empty_string(self): + self.assertEqual(normalize_tool_content("tool", []), "") + + def test_non_tool_role_unchanged(self): + content = [{"type": "text", "text": "hi"}] + result = normalize_tool_content("user", content) + self.assertIs(result, content) + + def test_mixed_str_and_dict_parts(self): + result = normalize_tool_content( + "tool", ["plain", {"type": "text", "text": "rich"}] + ) + self.assertEqual(result, "plain rich") + + if __name__ == "__main__": unittest.main(verbosity=2)