From 77e21ae37a57e4330ff130f9b74f4a49a049e333 Mon Sep 17 00:00:00 2001 From: Yucheng Date: Mon, 5 Oct 2026 00:03:39 -0700 Subject: [PATCH] fix(responses): read prompt_cache_breakpoint without validating content blocks The marker read ran every chat content block through TypeAdapter(dict[str, object]).validate_python, which rejects dict blocks with non-string keys that chat completion callers passing Python dicts could previously send; the request then failed with a pydantic ValidationError on the bridge keep path, the strip path, and the image/file conversions alike. item is already isinstance-narrowed to a dict at every read site, so read the marker with dict.get directly and drop the adapter. Adds a regression test covering text/image_url/file blocks carrying non-string keys on both the keep (gpt-5.6) and strip (gpt-4o) paths. --- .../transformation.py | 9 ++--- ...responses_transformation_transformation.py | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index d69b2915067..9a710a5ac99 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -19,7 +19,7 @@ from openai.types.responses.response_input_param import ( from openai.types.responses.tool_choice_custom_param import ToolChoiceCustomParam from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam from openai.types.responses.tool_param import FunctionToolParam -from pydantic import BaseModel, TypeAdapter +from pydantic import BaseModel import litellm from litellm import ModelResponse @@ -78,7 +78,6 @@ _CHAT_COMPLETION_FIELDS: Final = frozenset((*ModelResponse.model_fields, "usage" _RESPONSES_API_ONLY_FIELDS: Final = frozenset((*Response.model_fields, *ResponsesAPIResponse.model_fields)) - frozenset( ChatCompletion.model_fields ) -_CHAT_CONTENT_ITEM: Final = TypeAdapter(dict[str, object]) def _strip_prompt_cache_breakpoint_from_content_block(value: object) -> object: @@ -1118,7 +1117,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if original_type == "text": converted = with_prompt_cache_breakpoint( self._convert_content_str_to_input_text(item.get("text", ""), role), - _CHAT_CONTENT_ITEM.validate_python(item).get("prompt_cache_breakpoint"), + item.get("prompt_cache_breakpoint"), ) result.append(converted) verbose_logger.debug("Chat provider: text -> %s", converted) @@ -1131,7 +1130,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): role, ) ), - _CHAT_CONTENT_ITEM.validate_python(item).get("prompt_cache_breakpoint"), + item.get("prompt_cache_breakpoint"), ) result.append(converted) verbose_logger.debug("Chat provider: image_url -> %s", converted) @@ -1147,7 +1146,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): _input_file_from_file_value( cast("ChatCompletionFileObject", item).get("file"), # cast-ok: type tag checked ), - _CHAT_CONTENT_ITEM.validate_python(item).get("prompt_cache_breakpoint"), + item.get("prompt_cache_breakpoint"), ) result.append(converted) verbose_logger.debug("Chat provider: file -> %s", converted) 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 fcb44958ffb..ff48ef545cd 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 @@ -4233,6 +4233,44 @@ def test_prompt_cache_breakpoint_survives_chat_to_responses_conversion( assert request["prompt_cache_options"] == cache_breakpoint +def test_prompt_cache_breakpoint_read_tolerates_non_string_content_block_keys() -> None: + handler: Final = LiteLLMResponsesTransformationHandler() + # Non-string keys are not JSON-representable but are accepted by chat completion + # callers passing Python dicts; reading the marker must not validate or reject them. + content: Final = [ + {"type": "text", "text": "Stable prefix", 1: "ignored"}, + {"type": "image_url", "image_url": "https://example.com/image.png", 2: "ignored"}, + {"type": "file", "file": {"file_id": "file-123"}, 3: "ignored"}, + ] + messages: Final = [{"role": "user", "content": content}] + + for model in ("gpt-5.6", "gpt-4o"): # marker keep path and strip path both read the block + request: dict[str, object] = handler.transform_request( + model=model, + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + litellm_logging_obj=Mock(), + ) + + assert request["input"] == [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Stable prefix"}, + { + "type": "input_image", + "image_url": "https://example.com/image.png", + "detail": "auto", + }, + {"type": "input_file", "file_id": "file-123"}, + ], + } + ] + + def test_prompt_cache_breakpoints_are_dropped_for_unsupported_models() -> None: handler: Final = LiteLLMResponsesTransformationHandler() cache_breakpoint: Final = {"mode": "explicit"}