fix(responses-bridge): join folded system blocks on newlines; keep a guardrail's rewritten system item in place

Three follow-ups from review of the list-content fold:

- Folded blocks join on newlines with empty blocks dropped, as the
  Anthropic-to-Responses adapter joins the same shape, so a multi-block
  system prompt keeps its section boundaries.
- The Responses guardrail handler converts one rewritten chat row through
  the bridge to patch its input item in place. A lone list-content system
  row now folds into `instructions` and returns no item, so a guardrail
  rewriting a system item abandoned the patch and the request lost its
  system item (an empty `input` for a system-only request). The row now
  converts as a developer row, whose content maps to the same input parts.
- The system-only comment no longer cites non-string content, and the
  mid-conversation list test is dropped: main already covers that case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
poodle64 2026-10-10 20:45:36 +10:00
parent cc8e6c7a45
commit b118d8b22e
4 changed files with 59 additions and 15 deletions

View file

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

View file

@ -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]
)

View file

@ -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": "<total_tokens>14982391 tokens left</total_tokens>"}],
"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("<total_tokens>14982391 tokens left</total_tokens>"),
]
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():

View file

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