From 079d6e40fd595d4bbe3ecdd910e44e1667f29603 Mon Sep 17 00:00:00 2001 From: Genmin Date: Thu, 30 Apr 2026 08:19:20 -0700 Subject: [PATCH] fix: merge developer role into leading system message --- litellm/llms/base_llm/base_utils.py | 54 +++++++++++-- .../test_base_llm_base_utils.py | 79 +++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 tests/llm_translation/test_base_llm_base_utils.py diff --git a/litellm/llms/base_llm/base_utils.py b/litellm/llms/base_llm/base_utils.py index d2d3d5c0a96..9e5f12c9b05 100644 --- a/litellm/llms/base_llm/base_utils.py +++ b/litellm/llms/base_llm/base_utils.py @@ -5,7 +5,7 @@ Utility functions for base LLM classes. import copy import json from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union, cast from openai.lib import _parsing, _pydantic from pydantic import BaseModel @@ -215,13 +215,55 @@ def map_developer_role_to_system_role( """ Translate `developer` role to `system` role for non-OpenAI providers. """ - new_messages: List[AllMessageValues] = [] + if not any(m["role"] == "developer" for m in messages): + return messages + + system_message: Optional[Dict[str, Any]] = None + system_contents: List[Any] = [] + non_system_messages: List[AllMessageValues] = [] for m in messages: + if m["role"] in {"developer", "system"}: + if system_message is None: + system_message = dict(m) + system_message["role"] = "system" + system_contents.append(m["content"]) + else: + non_system_messages.append(m) + if m["role"] == "developer": verbose_logger.debug( "Translating developer role to system role for non-OpenAI providers." ) # ensure user knows what's happening with their input. - new_messages.append({"role": "system", "content": m["content"]}) - else: - new_messages.append(m) - return new_messages + + if system_message is None: + return non_system_messages + + system_message["content"] = _merge_system_message_contents(system_contents) + return [cast(AllMessageValues, system_message), *non_system_messages] + + +def _merge_system_message_contents(contents: List[Any]) -> Union[str, List[Any]]: + """ + Merge system-equivalent content into one leading system message. + """ + if all(isinstance(content, str) or content is None for content in contents): + return "\n\n".join(content for content in contents if content) + + merged_blocks: List[Any] = [] + for content in contents: + content_blocks = _system_message_content_to_blocks(content) + if not content_blocks: + continue + + if merged_blocks: + merged_blocks.append({"type": "text", "text": "\n\n"}) + merged_blocks.extend(content_blocks) + return merged_blocks + + +def _system_message_content_to_blocks(content: Any) -> List[Any]: + if content is None or content == "": + return [] + if isinstance(content, list): + return list(content) + return [{"type": "text", "text": str(content)}] diff --git a/tests/llm_translation/test_base_llm_base_utils.py b/tests/llm_translation/test_base_llm_base_utils.py new file mode 100644 index 00000000000..a7fa0c353b8 --- /dev/null +++ b/tests/llm_translation/test_base_llm_base_utils.py @@ -0,0 +1,79 @@ +from litellm.llms.base_llm.base_utils import map_developer_role_to_system_role +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) + + +def test_map_developer_role_leaves_messages_without_developer_role_unchanged(): + messages = [ + {"role": "system", "content": "Follow the product policy."}, + {"role": "user", "content": "Hello!"}, + ] + + assert map_developer_role_to_system_role(messages=messages) is messages + + +def test_map_developer_role_merges_system_equivalent_messages(): + messages = [ + {"role": "system", "content": "Follow the product policy."}, + {"role": "developer", "content": "Prefer concise answers."}, + {"role": "user", "content": "Hello!"}, + {"role": "system", "content": "Use markdown only when helpful."}, + ] + + result = map_developer_role_to_system_role(messages=messages) + + assert result == [ + { + "role": "system", + "content": ( + "Follow the product policy.\n\n" + "Prefer concise answers.\n\n" + "Use markdown only when helpful." + ), + }, + {"role": "user", "content": "Hello!"}, + ] + + +def test_map_developer_role_preserves_structured_system_content(): + messages = [ + {"role": "system", "content": [{"type": "text", "text": "System rules."}]}, + {"role": "developer", "content": "Developer rules."}, + {"role": "user", "content": "Hello!"}, + ] + + result = map_developer_role_to_system_role(messages=messages) + + assert result == [ + { + "role": "system", + "content": [ + {"type": "text", "text": "System rules."}, + {"type": "text", "text": "\n\n"}, + {"type": "text", "text": "Developer rules."}, + ], + }, + {"role": "user", "content": "Hello!"}, + ] + + +def test_responses_instructions_and_developer_input_become_single_system_message(): + request = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( + model="anthropic/claude-sonnet-4-5", + input=[ + {"role": "developer", "content": "Prefer concise answers."}, + {"role": "user", "content": "Hello!"}, + ], + responses_api_request={"instructions": "Follow the product policy."}, + ) + + result = map_developer_role_to_system_role(messages=request["messages"]) + + assert result == [ + { + "role": "system", + "content": "Follow the product policy.\n\nPrefer concise answers.", + }, + {"role": "user", "content": "Hello!"}, + ]