diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index cc594f167c7..1738cfc5da7 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -434,22 +434,59 @@ class LiteLLMCompletionResponsesConfig: | ChatCompletionResponseMessage | Message ] = [] - if responses_api_request.get("instructions"): - messages.append( - LiteLLMCompletionResponsesConfig.transform_instructions_to_system_message( - responses_api_request.get("instructions") - ) - ) - - messages.extend( + input_messages: Final = tuple( LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message( input=input, replay_reasoning=replay_reasoning, ) ) + # `instructions` and the input can each carry a system message, which left a + # conversation reading system, user, system. Chat templates that require the + # system message first reject that, so gather them into one leading message. + instructions: Final = responses_api_request.get("instructions") + system_contents: Final = tuple( + content + for content in ( + instructions, + *(message.get("content") for message in input_messages if message.get("role") == "system"), + ) + if content + ) + + if system_contents: + messages.append(LiteLLMCompletionResponsesConfig._merge_system_contents(system_contents)) + + messages.extend(message for message in input_messages if message.get("role") != "system") + return messages + @staticmethod + def _merge_system_contents(contents: tuple[Any, ...]) -> ChatCompletionSystemMessage: + """Join system prompts into one leading message. + + Part lists collapse to their text: system content is conventionally a string, and + the backends that reject a trailing system message are the same ones that expect one. + """ + texts: Final = tuple( + text for content in contents for text in LiteLLMCompletionResponsesConfig._system_content_texts(content) + ) + return ChatCompletionSystemMessage(role="system", content="\n\n".join(text for text in texts if text)) + + @staticmethod + def _system_content_texts(content: object) -> tuple[str, ...]: + """Every text a system content field carries, as a plain string or as a part list + that may mix bare strings with text parts.""" + if isinstance(content, str): + return (content,) + if not isinstance(content, (list, tuple)): + return () + return tuple( + part if isinstance(part, str) else str(part.get("text", "")) + for part in content + if isinstance(part, (str, dict)) + ) + @staticmethod async def async_responses_api_session_handler( previous_response_id: str, diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_system_message_hoisting.py b/tests/test_litellm/responses/litellm_completion_transformation/test_system_message_hoisting.py new file mode 100644 index 00000000000..4b91694d73b --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_system_message_hoisting.py @@ -0,0 +1,89 @@ +""" +Unit tests for keeping system messages at the front of the bridged chat request. + +``instructions`` becomes a leading system message and the Responses input can carry +a system message of its own, so an Anthropic ``/v1/messages`` conversation bridged +to chat completions could come out as system, user, system. Chat templates that +require the system message to come first reject that with +"System message must be at the beginning". + +See: https://github.com/BerriAI/litellm/issues/40693 +""" + +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) + + +def _roles(input, responses_api_request): + messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=input, responses_api_request=responses_api_request + ) + return [message.get("role") for message in messages], messages + + +def test_system_message_after_user_content_is_hoisted(): + roles, messages = _roles( + [ + {"role": "user", "content": "first question"}, + {"role": "system", "content": "mid"}, + {"role": "user", "content": "second question"}, + ], + {"instructions": "lead"}, + ) + + assert roles == ["system", "user", "user"] + assert "system" not in roles[1:] + + +def test_both_system_prompts_survive_the_merge(): + _, messages = _roles( + [ + {"role": "user", "content": "q"}, + {"role": "system", "content": "mid"}, + ], + {"instructions": "lead"}, + ) + + assert messages[0]["content"] == "lead\n\nmid" + + +def test_part_list_content_collapses_to_its_text(): + """A system message given as parts still contributes its text to the merged prompt.""" + _, messages = _roles( + [ + {"role": "user", "content": "q"}, + {"role": "system", "content": [{"type": "text", "text": "B"}]}, + ], + {"instructions": "A"}, + ) + + assert messages[0]["content"] == "A\n\nB" + + +def test_an_already_leading_system_message_is_left_alone(): + """The common case must not be rewritten.""" + roles, messages = _roles([{"role": "user", "content": "q"}], {"instructions": "lead"}) + + assert roles == ["system", "user"] + assert messages[0]["content"] == "lead" + + +def test_a_conversation_without_a_system_message_is_unchanged(): + roles, _ = _roles([{"role": "user", "content": "q"}], {}) + + assert roles == ["user"] + + +def test_bare_strings_in_a_part_list_survive_the_merge(): + """Upstream normalization leaves plain strings in a content list, so filtering the + list down to dicts silently dropped part of the prompt.""" + _, messages = _roles( + [ + {"role": "user", "content": "q"}, + {"role": "system", "content": ["B", {"type": "text", "text": "C"}]}, + ], + {"instructions": "A"}, + ) + + assert messages[0]["content"] == "A\n\nB\n\nC"