From 6a0d30bcab0c8274f9a8f3039d8dc7de4867cb37 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:53:09 -0700 Subject: [PATCH] fix(responses): keep the caller's request stripped of cache_control as before when a marker is copied --- .../llms/openai/responses/transformation.py | 43 +++++++++++++------ .../test_openai_responses_transformation.py | 40 +++++++++++++++++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 7d09b572291..180b9b8589b 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -586,12 +586,8 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): marker_safe_input: Final = self._input_with_wire_safe_prompt_cache_breakpoints( model=model, input=validated_input, litellm_params=litellm_params ) - marked_input: Final = ( - self._input_with_prompt_cache_breakpoints(marker_safe_input) - if self._reads_prompt_cache_breakpoints( - model=model, litellm_params=litellm_params, prompt_cache_options=prompt_cache_options - ) - else marker_safe_input + reads_breakpoints: Final = self._reads_prompt_cache_breakpoints( + model=model, litellm_params=litellm_params, prompt_cache_options=prompt_cache_options ) maps_anthropic_tool_search: Final = self._targets_openai_api(litellm_params) and any( is_anthropic_tool_search_chat_tool(tool) for tool in tools or () @@ -601,20 +597,43 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): if maps_anthropic_tool_search else tools ) - replayable_input: Final = ( - input_without_unanswered_anthropic_tool_search_calls(marked_input) - if maps_anthropic_tool_search - else marked_input - ) stripped_input, stripped_tools = self.remove_cache_control_flag_from_input_and_tools( - model=model, input=replayable_input, tools=mapped_tools + model=model, + input=self._replayable_input(marker_safe_input, reads_breakpoints, maps_anthropic_tool_search), + tools=mapped_tools, ) + if marker_safe_input is not validated_input: + self._strip_caller_input_as_if_unmarked( + model=model, + caller_input=validated_input, + reads_breakpoints=reads_breakpoints, + maps_anthropic_tool_search=maps_anthropic_tool_search, + ) object_schema_tools: Final = self._tools_with_object_parameters(model=model, tools=stripped_tools) sanitized_tools: Final = self._sanitized_tool_schemas_for_openai( model=model, tools=object_schema_tools, litellm_params=litellm_params ) return self._drop_foreign_tool_call_item_ids(stripped_input), sanitized_tools + def _replayable_input( + self, input: str | ResponseInputParam, reads_breakpoints: bool, maps_anthropic_tool_search: bool + ) -> str | ResponseInputParam: + marked_input: Final = self._input_with_prompt_cache_breakpoints(input) if reads_breakpoints else input + if not maps_anthropic_tool_search: + return marked_input + return input_without_unanswered_anthropic_tool_search_calls(marked_input) + + def _strip_caller_input_as_if_unmarked( + self, + model: str, + caller_input: str | ResponseInputParam, + reads_breakpoints: bool, + maps_anthropic_tool_search: bool, + ) -> None: + self.remove_cache_control_flag_from_input_and_tools( + model=model, input=self._replayable_input(caller_input, reads_breakpoints, maps_anthropic_tool_search) + ) + def _input_with_wire_safe_prompt_cache_breakpoints( self, model: str, input: str | ResponseInputParam, litellm_params: GenericLiteLLMParams ) -> str | ResponseInputParam: diff --git a/tests/unit/llms/openai/responses/test_openai_responses_transformation.py b/tests/unit/llms/openai/responses/test_openai_responses_transformation.py index 8d51a6c8639..c83299a6ef4 100644 --- a/tests/unit/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/unit/llms/openai/responses/test_openai_responses_transformation.py @@ -4382,6 +4382,46 @@ def test_cache_control_still_marks_a_part_whose_own_marker_is_malformed_under_dr } +def _caller_input_after_transform(model: str, marker: object) -> str: + hint: Final = {"type": "ephemeral"} + marked: Final = {} if marker is None else {"prompt_cache_breakpoint": marker} + input: Final = [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Say ok", "cache_control": hint, **marked}, + {"type": "input_text", "text": "Thanks", "cache_control": hint}, + ], + "cache_control": hint, + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "cache_control": hint, **marked}], + }, + ] + _CONFIGS[0].transform_responses_api_request( + model=model, + input=cast("ResponseInputParam", input), # cast-ok: plain dicts shaped like the SDK's input items + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(drop_params=True), + headers={}, + ) + return json.dumps(input) + + +@pytest.mark.parametrize("model", ("gpt-4.1", "gpt-6.1-sol")) +@pytest.mark.parametrize("marker", (_VALID_MARKERS[0], "yes"), ids=("explicit", "malformed")) +def test_a_marker_leaves_the_caller_request_that_fallbacks_and_logs_read_as_an_unmarked_one_would( + monkeypatch: pytest.MonkeyPatch, model: str, marker: object +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + marker_json: Final = f', "prompt_cache_breakpoint": {json.dumps(marker)}' + unmarked: Final = _caller_input_after_transform(model, None) + assert _caller_input_after_transform(model, marker).replace(marker_json, "") == unmarked + + @pytest.mark.asyncio @pytest.mark.usefixtures(httpx_transport.__name__) async def test_aresponses_forwards_previous_response_id_to_openai() -> None: