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.
This commit is contained in:
Yucheng 2026-10-05 00:03:39 -07:00
parent ccf03ee7df
commit 77e21ae37a
2 changed files with 42 additions and 5 deletions

View file

@ -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)

View file

@ -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"}