This commit is contained in:
Animesh Kumar 2026-09-13 05:38:38 +08:00 committed by GitHub
commit d69670dc73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 134 additions and 8 deletions

View file

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

View file

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