From 206b74effa1bf395d985d41a9ce1361e6c71ad92 Mon Sep 17 00:00:00 2001 From: Animesh Kumar Date: Fri, 11 Sep 2026 15:12:02 +0530 Subject: [PATCH 1/3] fix(responses): keep system messages at the front of the bridged request instructions became a leading system message and the Responses input could carry one of its own, so an Anthropic /v1/messages conversation bridged to chat completions came out as system, user, system. Chat templates that require the system message first reject that with "System message must be at the beginning", which is how Claude Code against an OpenAI-compatible backend hits it. Gather the system prompts into one leading message instead. A conversation whose system message is already first is untouched. Fixes #40693 --- .../transformation.py | 44 +++++++++-- .../test_system_message_hoisting.py | 75 +++++++++++++++++++ 2 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_system_message_hoisting.py diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index fca5b0d11cf..5c11ff7e78c 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -433,22 +433,50 @@ 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( + content + if isinstance(content, str) + else "\n\n".join(part.get("text", "") for part in content if isinstance(part, dict)) + for content in contents + ) + return ChatCompletionSystemMessage(role="system", content="\n\n".join(text for text in texts if text)) + @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..e58a68838fb --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_system_message_hoisting.py @@ -0,0 +1,75 @@ +""" +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"] From e327aefe3baff8eb42d79e039874e471884de2d6 Mon Sep 17 00:00:00 2001 From: Animesh Kumar Date: Fri, 11 Sep 2026 15:13:41 +0530 Subject: [PATCH 2/3] style: apply ruff formatting to the system message merge --- .../litellm_completion_transformation/transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 5c11ff7e78c..5501e87bfe4 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -454,9 +454,7 @@ class LiteLLMCompletionResponsesConfig: ) if system_contents: - messages.append( - LiteLLMCompletionResponsesConfig._merge_system_contents(system_contents) - ) + messages.append(LiteLLMCompletionResponsesConfig._merge_system_contents(system_contents)) messages.extend(message for message in input_messages if message.get("role") != "system") From 18de9e6effc49351f878ad1692f3f1937ad07462 Mon Sep 17 00:00:00 2001 From: Animesh Kumar Date: Fri, 11 Sep 2026 22:39:50 +0530 Subject: [PATCH 3/3] fix(responses): keep bare strings in a system content part list Filtering a content list down to dicts dropped plain strings, which upstream normalization leaves in place, so part of the system prompt went missing from the merged message. --- .../transformation.py | 19 +++++++++++++++---- .../test_system_message_hoisting.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 5501e87bfe4..b503ea363b1 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -468,13 +468,24 @@ class LiteLLMCompletionResponsesConfig: the backends that reject a trailing system message are the same ones that expect one. """ texts: Final = tuple( - content - if isinstance(content, str) - else "\n\n".join(part.get("text", "") for part in content if isinstance(part, dict)) - for content in contents + 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 index e58a68838fb..4b91694d73b 100644 --- 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 @@ -73,3 +73,17 @@ 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"