fix(responses-bridge): keep a system message carrying a prompt cache breakpoint an input item

Since this branch was cut, main carries prompt_cache_breakpoint markers through the
bridge on input items' content blocks (#42281, #44119, #45500), and its tests expect a
leading system message whose block carries one to stay an input item so the marker
survives. Folding that message into `instructions` dropped the marker and broke eight
of those tests.

The fold now skips a leading system message any of whose blocks carries a
prompt_cache_breakpoint, alongside the non-text case it already skipped. A block with
only cache_control, the shape #42171 reports, still folds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
poodle64 2026-10-10 20:18:20 +10:00
parent 921bb461de
commit cc8e6c7a45
2 changed files with 52 additions and 18 deletions

View file

@ -129,6 +129,35 @@ def _breakpoint_of(part: object) -> object:
return content_part.get("prompt_cache_breakpoint")
def _foldable_system_text(block: object) -> str | None:
if isinstance(block, str):
return block
if not isinstance(block, dict):
return None
text_block: Final = cast(dict[str, object], block) # cast-ok: isinstance confirms a content block mapping
text: Final = text_block.get("text")
if text_block.get("type") != "text" or not isinstance(text, str) or _breakpoint_of(text_block) is not None:
return None
return text
def _system_text_for_instructions(message: Mapping[str, object]) -> str | None:
"""A leading system message's text for ``instructions``, or None to keep it a positioned input item.
List content folds when every block is plain text, the shape a client attaching cache_control sends.
A non-text block (an image, say) would be lost in a string, and a prompt_cache_breakpoint only rides
on an input item's content block, so either keeps the message an input item.
"""
content: Final = message.get("content", "")
if isinstance(content, str):
return content
if not isinstance(content, list):
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
def _pending_audio_breakpoint(pending: object, part: object) -> object:
if not _is_audio_input_part(part):
return None
@ -536,24 +565,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
tool_call_id = msg.get("tool_call_id")
if role == "system":
# Extract system message as instructions, but only within the leading run of
# system messages: a later one stays a positioned input item so its bytes don't
# unsettle the prompt-cache-stable prefix (#40269).
extracted_instructions: str | None = None
if index < leading_system_count:
if isinstance(content, str):
extracted_instructions = content
elif isinstance(content, list) and all(
isinstance(block, str) or (isinstance(block, dict) and block.get("type") == "text")
for block in content
):
# Every block is plain text (a bare string or a `{"type": "text", ...}`
# dict), the same shape a client attaching cache_control sends; a
# non-text block (an image, say) fails the `all()` above and falls
# through to the input-item branch below instead of losing it silently.
extracted_instructions = " ".join(
block if isinstance(block, str) else block.get("text", "") for block in content
)
# Only the leading run of system messages folds into instructions: a later one stays
# a positioned input item so its bytes don't unsettle the cached prefix (#40269).
extracted_instructions = _system_text_for_instructions(msg) if index < leading_system_count else None
if extracted_instructions is not None:
if instructions:
# Concatenate multiple system prompts with a space

View file

@ -4887,6 +4887,26 @@ def test_leading_system_list_with_a_non_text_block_stays_an_input_item():
assert input_items[0]["content"][1]["type"] == "input_image"
def test_leading_system_list_with_a_prompt_cache_breakpoint_stays_an_input_item():
"""A prompt_cache_breakpoint rides only on an input item's content block, so a leading
system message carrying one stays an input item and keeps its marker rather than folding."""
handler: Final = LiteLLMResponsesTransformationHandler()
input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(
[
{"role": "system", "content": [_MARKED_SYSTEM_PART]},
{"role": "user", "content": "hi"},
],
keep_prompt_cache_breakpoints=True,
)
assert instructions is None
assert input_items == [
{"type": "message", "role": "system", "content": [{**_MARKED_SYSTEM_PART, "type": "input_text"}]},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]},
]
def test_map_optional_params_verbosity_merges_into_text():
"""Chat verbosity must land on Responses text.verbosity alongside text.format regardless of key order."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (