diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py
index d6c73aef03c..3f3cc310858 100644
--- a/litellm/completion_extras/litellm_responses_transformation/transformation.py
+++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py
@@ -155,7 +155,8 @@ def _system_text_for_instructions(message: Mapping[str, object]) -> str | None:
return None
blocks: Final = cast(list[object], content) # cast-ok: isinstance confirms a list of content blocks
texts: Final = tuple(text for text in map(_foldable_system_text, blocks) if text is not None)
- return " ".join(texts) if len(texts) == len(blocks) else None
+ # Newline-joined, empty blocks dropped, as the Anthropic-to-Responses adapter joins the same shape.
+ return "\n".join(filter(None, texts)) if len(texts) == len(blocks) else None
def _pending_audio_breakpoint(pending: object, part: object) -> object:
@@ -791,8 +792,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
)
# OpenAI's Responses API rejects an empty input. For a system-only
# request, carry the system message as a system-role input item instead
- # of instructions, mirroring how non-string system content is already
- # handled in convert_chat_completion_messages_to_responses_api.
+ # of instructions.
is_system_only_request: Final = not converted_input_items and converted_instructions is not None
target_input_items: Final = (
_without_audio_input_parts(converted_input_items)
diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py
index a267b66bb22..da0fc62f962 100644
--- a/litellm/llms/openai/responses/guardrail_translation/handler.py
+++ b/litellm/llms/openai/responses/guardrail_translation/handler.py
@@ -265,7 +265,12 @@ def _rewritten_input_item(item: Mapping[str, object], rewritten: object) -> Mapp
rewritten_content: Final = rewritten.get("content")
if isinstance(item.get(field), str) and isinstance(rewritten_content, str):
return {**item, field: rewritten_content}
- rewritten_row: Final = cast("AllMessageValues", rewritten) # cast-ok: guardrails hand back chat-shaped rows
+ # A lone system row with content would fold into `instructions` and leave no item to patch; as a developer
+ # row its content converts to the same input parts and stays an item.
+ row: Final = cast("Mapping[str, object]", rewritten) # cast-ok: isinstance confirms a mapping, keyed by field name
+ folds: Final = row.get("role") == "system" and row.get("content") is not None
+ chat_row: Final = {**row, "role": "developer"} if folds else row
+ rewritten_row: Final = cast("AllMessageValues", chat_row) # cast-ok: guardrails hand back chat-shaped rows
converted_items, _ = LiteLLMResponsesTransformationHandler().convert_chat_completion_messages_to_responses_api(
[rewritten_row]
)
diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py
index 10527607308..e186f2588d3 100644
--- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py
+++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py
@@ -4840,26 +4840,42 @@ def test_leading_system_messages_mixed_str_and_list_concatenate_in_order():
]
-def test_mid_conversation_system_list_content_stays_in_input_after_a_user_turn():
- """Only the leading run folds (#40269): a list-content system message after the first
- non-system message stays a positioned input item."""
+def test_leading_system_list_blocks_join_on_newlines_without_empty_blocks():
+ """A multi-block system prompt keeps its section boundaries: blocks join on newlines and
+ empty blocks drop, as the Anthropic-to-Responses adapter joins the same shape."""
handler: Final = LiteLLMResponsesTransformationHandler()
- input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(
+ _, instructions = handler.convert_chat_completion_messages_to_responses_api(
[
- {"role": "user", "content": "Read the file."},
{
"role": "system",
- "content": [{"type": "text", "text": "14982391 tokens left"}],
+ "content": [
+ {"type": "text", "text": "You are Claude Code."},
+ {"type": "text", "text": ""},
+ {"type": "text", "text": "# Tone\nBe brief.", "cache_control": {"type": "ephemeral"}},
+ ],
},
+ {"role": "user", "content": "hi"},
]
)
- assert instructions is None
- assert input_items == [
- {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Read the file."}]},
- _system_input_item("14982391 tokens left"),
- ]
+ assert instructions == "You are Claude Code.\n# Tone\nBe brief."
+
+
+def test_system_only_request_with_list_content_stays_a_system_input_item():
+ """Responses rejects an empty input, so a request holding only a list-format system
+ message still goes out as one system input item, as a string one does."""
+ request: Final = LiteLLMResponsesTransformationHandler().transform_request(
+ model="gpt-4o",
+ messages=[{"role": "system", "content": [{"type": "text", "text": "You are terse."}]}],
+ optional_params={},
+ litellm_params={},
+ headers={},
+ litellm_logging_obj=Mock(),
+ )
+
+ assert "instructions" not in request
+ assert request["input"] == [_system_input_item("You are terse.")]
def test_leading_system_list_with_a_non_text_block_stays_an_input_item():
diff --git a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py
index 8bd242308f7..d3cd2479a46 100644
--- a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py
+++ b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py
@@ -3105,6 +3105,29 @@ class TestPatchEdgeBranches:
user_items = [item for item in result["input"] if item.get("role") == "user"]
assert _texts(user_items[0]) == [COMPRESSED_MARKER]
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("trailing", [[{"role": "user", "content": "What is the codename?"}], []])
+ async def test_list_content_system_item_rewrite_patches_in_place(self, trailing):
+ """A rewritten list-content system item stays an input item, never moved into instructions."""
+ handler = OpenAIResponsesHandler()
+ data = {
+ "model": "gpt-5.6",
+ "input": [
+ {"role": "system", "content": [{"type": "input_text", "text": "Answer from the memo only."}]},
+ *trailing,
+ ],
+ }
+
+ result = await handler.process_input_messages(
+ data, SystemRewriteGuardrail(rewritten_content=[{"type": "text", "text": COMPRESSED_MARKER}])
+ )
+
+ assert "instructions" not in result
+ assert result["input"] == [
+ {"role": "system", "content": [{"type": "input_text", "text": COMPRESSED_MARKER}]},
+ *trailing,
+ ]
+
def test_item_rewrite_field_ignores_non_string_type(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _item_rewrite_field