diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index c25d7bd9410..d6c73aef03c 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -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 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 82a23a0f8be..10527607308 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 @@ -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 (