mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
parent
cc8e6c7a45
commit
b118d8b22e
4 changed files with 59 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue