From f09b8ce34af3e2521faf87a386835c6a42a32b35 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Wed, 8 Jul 2026 21:59:31 -0400 Subject: [PATCH] fix(responses): propagate message cache_control safely through objects and models --- .../context_caching/transformation.py | 50 ++++-- .../transformation.py | 45 +++-- litellm/types/llms/openai.py | 1 + .../test_context_caching_ttl.py | 157 ++++++++++++++++-- .../test_litellm_completion_responses.py | 148 +++++------------ 5 files changed, 248 insertions(+), 153 deletions(-) diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index 183d9743f13..bc6ee47d3b8 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -59,32 +59,52 @@ def extract_ttl_from_cached_messages(messages: List[AllMessageValues]) -> Option Optional[str]: TTL string in format "3600s" or None if not found/invalid """ for message in messages: - if not is_cached_message(message): - continue + # Check message-level cache_control first + msg_cache_control = ( + message.get("cache_control") if isinstance(message, dict) else getattr(message, "cache_control", None) + ) + if msg_cache_control is not None: + cc_type = ( + msg_cache_control.get("type") + if isinstance(msg_cache_control, dict) + else getattr(msg_cache_control, "type", None) + ) + if cc_type == "ephemeral": + ttl = ( + msg_cache_control.get("ttl") + if isinstance(msg_cache_control, dict) + else getattr(msg_cache_control, "ttl", None) + ) + if ttl and _is_valid_ttl_format(ttl): + return str(ttl) content = message.get("content") if isinstance(message, dict) else getattr(message, "content", None) - if not content or isinstance(content, str): + if not isinstance(content, list): continue for content_item in content: # Check if content_item is dict or object model if isinstance(content_item, dict): cache_control = content_item.get("cache_control") + item_type = content_item.get("type") else: cache_control = getattr(content_item, "cache_control", None) + item_type = getattr(content_item, "type", None) - if not cache_control: - continue - - cc_type = ( - cache_control.get("type") if isinstance(cache_control, dict) else getattr(cache_control, "type", None) - ) - if cc_type != "ephemeral": - continue - - ttl = cache_control.get("ttl") if isinstance(cache_control, dict) else getattr(cache_control, "ttl", None) - if ttl and _is_valid_ttl_format(ttl): - return str(ttl) + if item_type == "text" and cache_control is not None: + cc_type = ( + cache_control.get("type") + if isinstance(cache_control, dict) + else getattr(cache_control, "type", None) + ) + if cc_type == "ephemeral": + ttl = ( + cache_control.get("ttl") + if isinstance(cache_control, dict) + else getattr(cache_control, "ttl", None) + ) + if ttl and _is_valid_ttl_format(ttl): + return str(ttl) return None diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 217ed711d37..4105fc0bcf5 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -912,9 +912,8 @@ class LiteLLMCompletionResponsesConfig: content=LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( content ), + **({"cache_control": cache_control} if cache_control is not None else {}), ) - if cache_control is not None: - msg["cache_control"] = cache_control return [msg] @staticmethod @@ -948,6 +947,11 @@ class LiteLLMCompletionResponsesConfig: """ ChatCompletionToolMessage is used to indicate the output from a tool call """ + if not isinstance(tool_call_output, dict): + tool_call_output = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict( + tool_call_output + ) + call_id = tool_call_output.get("call_id") # If call_id is missing or empty, skip this message # Empty call_id means we can't create a valid tool message @@ -1097,6 +1101,9 @@ class LiteLLMCompletionResponsesConfig: } ``` """ + if not isinstance(function_call, dict): + function_call = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict(function_call) + # Create a tool call for the function call. Custom tool calls # store their payload in "input" (raw string) rather than # "arguments" (JSON string), so normalize to arguments here. @@ -1178,8 +1185,7 @@ class LiteLLMCompletionResponsesConfig: elif hasattr(item, "dict"): return item.dict() - item_dict = {} - for attr in [ + target_attrs = ( "type", "text", "cache_control", @@ -1188,12 +1194,19 @@ class LiteLLMCompletionResponsesConfig: "file_url", "image_url", "detail", - ]: - if hasattr(item, attr): - val = getattr(item, attr) - if val is not None: - item_dict[attr] = val - return item_dict + "call_id", + "arguments", + "input", + "name", + "id", + "output", + "status", + ) + return { + attr: getattr(item, attr) + for attr in target_attrs + if hasattr(item, attr) and getattr(item, attr) is not None + } @staticmethod def _transform_responses_api_content_to_chat_completion_content( @@ -1225,11 +1238,10 @@ class LiteLLMCompletionResponsesConfig: LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(item) ) elif item.get("type") == "input_image": - image_block = dict( - LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item) - ) - if "cache_control" in item: - image_block["cache_control"] = item["cache_control"] + image_block = { + **LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item), + **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), + } content_list.append(image_block) else: # Skip text blocks with None text to avoid downstream errors @@ -1241,9 +1253,8 @@ class LiteLLMCompletionResponsesConfig: item.get("type") or "text" ), "text": text_value, + **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), } - if "cache_control" in item: - content_block["cache_control"] = item["cache_control"] content_list.append(content_block) return content_list else: diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 9f689a2dd31..77d1680eefd 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -791,6 +791,7 @@ class ChatCompletionDeveloperMessage(OpenAIChatCompletionDeveloperMessage, total class GenericChatCompletionMessage(TypedDict, total=False): role: Required[str] content: Required[Union[str, List]] + cache_control: ChatCompletionCachedContent ValidUserMessageContentTypes = [ diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py index 8a13baa0006..2b786820f8f 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py @@ -91,9 +91,7 @@ class TestTTLExtraction: messages = [ { "role": "user", - "content": [ - {"type": "text", "text": "Regular message without cache control"} - ], + "content": [{"type": "text", "text": "Regular message without cache control"}], } ] @@ -175,9 +173,7 @@ class TestTTLExtraction: class TestTransformationWithTTL: """Test the complete transformation with TTL support""" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_with_valid_ttl(self, custom_llm_provider): """Test transformation includes TTL when provided""" messages = [ @@ -218,9 +214,7 @@ class TestTransformationWithTTL: assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_without_ttl(self, custom_llm_provider): """Test transformation without TTL""" messages = [ @@ -260,9 +254,7 @@ class TestTransformationWithTTL: assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_with_invalid_ttl(self, custom_llm_provider): """Test transformation with invalid TTL (should be ignored)""" messages = [ @@ -301,9 +293,7 @@ class TestTransformationWithTTL: assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_with_system_message_and_ttl(self, custom_llm_provider): """Test transformation with system message and TTL""" messages = [ @@ -388,6 +378,143 @@ class TestEdgeCases: assert isinstance(ttl, str) assert ttl == "3600s" + def test_cache_control_preserved_for_object_content_items(self): + """Test that cache_control is preserved when content items are real Pydantic models.""" + from pydantic import BaseModel, Field + from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, + ) + + class MockContentBlock: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = {"type": "ephemeral"} + + class RealPydanticV2Block(BaseModel): + type: str = "text" + text: str = "hello v2" + cache_control: dict = Field(default_factory=lambda: {"type": "ephemeral"}) + + class MockBlockWithNoneCacheControl: + def __init__(self): + self.type = "text" + self.text = "hello none" + self.cache_control = None + + content = [ + MockContentBlock(), + RealPydanticV2Block(), + MockBlockWithNoneCacheControl(), + ] + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) + assert result == [ + {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": "hello v2", "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": "hello none"}, + ] + + def test_is_cached_message_for_object_message_and_content_item(self): + """Test is_cached_message on custom objects / models.""" + from litellm.utils import is_cached_message + + # Test message level cache_control object + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + + class MockMessageLevelObj: + def __init__(self): + self.role = "system" + self.content = "hello" + self.cache_control = MockCacheControl() + + msg = MockMessageLevelObj() + assert is_cached_message(msg) is True + + # Test content level cache_control object + class MockContentItem: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = MockCacheControl() + + class MockContentLevelObj: + def __init__(self): + self.role = "system" + self.content = [MockContentItem()] + + msg = MockContentLevelObj() + assert is_cached_message(msg) is True + + def test_extract_ttl_from_cached_messages_for_object_models(self): + """Test extract_ttl_from_cached_messages with object-based messages and content items.""" + + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + self.ttl = "3600s" + + class MockContentItem: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = MockCacheControl() + + class MockMessageObj: + def __init__(self): + self.role = "system" + self.content = [MockContentItem()] + + messages = [MockMessageObj()] + ttl = extract_ttl_from_cached_messages(messages) + assert ttl == "3600s" + + def test_extract_ttl_from_cached_messages_with_message_level_object_cache_control(self): + """Test extract_ttl_from_cached_messages with message-level object cache_control.""" + + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + self.ttl = "7200s" + + class MockMessageObj: + def __init__(self): + self.role = "system" + self.content = "hello" + self.cache_control = MockCacheControl() + + messages = [MockMessageObj()] + ttl = extract_ttl_from_cached_messages(messages) + assert ttl == "7200s" + + def test_is_cached_message_for_dict_message_with_dict_content_items(self): + """Test is_cached_message with dict message and dict content list items.""" + from litellm.utils import is_cached_message + + # Dictionary message without content should return False + assert is_cached_message({"role": "user"}) is False + + msg = { + "role": "user", + "content": [ + {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} + ], + } + assert is_cached_message(msg) is True + + def test_normalize_responses_api_object_to_dict_pydantic_v1(self): + """Test _normalize_responses_api_object_to_dict with Pydantic v1 dict fallback.""" + from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig + + class MockPydanticV1Model: + def dict(self): + return {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} + + item = MockPydanticV1Model() + res = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict(item) + assert res == {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 537753ec3e1..b34bdd812d6 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2621,121 +2621,57 @@ class TestCacheControlPreservation: assert result[0]["cache_control"] == {"type": "ephemeral"} def test_cache_control_preserved_for_object_input_item(self): - """Test that cache_control is preserved when input_item is a custom object / model.""" + """Test that cache_control is preserved when input_item is a real Pydantic model.""" + from pydantic import BaseModel, Field - class MockInputItem: - def __init__(self): - self.role = "user" - self.content = "hello" - self.cache_control = {"type": "ephemeral"} + class RealInputItem(BaseModel): + role: str = "user" + content: str = "hello" + cache_control: dict = Field(default_factory=lambda: {"type": "ephemeral"}) - input_item = MockInputItem() + input_item = RealInputItem() messages = LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message( input_item ) + assert messages == [{"role": "user", "content": "hello", "cache_control": {"type": "ephemeral"}}] + + def test_tool_call_output_as_custom_object(self): + """Test _transform_responses_api_tool_call_output_to_chat_completion_message with a custom object.""" + class MockToolCallOutput: + def __init__(self): + self.call_id = "call_abc123" + self.output = "tool output content" + self.status = "completed" + + item = MockToolCallOutput() + messages = LiteLLMCompletionResponsesConfig._transform_responses_api_tool_call_output_to_chat_completion_message( + item + ) assert len(messages) == 1 - assert messages[0].get("cache_control") == {"type": "ephemeral"} + assert messages[0]["role"] == "tool" + assert messages[0]["tool_call_id"] == "call_abc123" + assert messages[0]["content"] == "tool output content" - def test_cache_control_preserved_for_object_content_item(self): - """Test that cache_control is preserved when content items are custom objects.""" - - class MockContentBlock: + def test_function_call_as_custom_object(self): + """Test _transform_responses_api_function_call_to_chat_completion_message with a custom object.""" + class MockFunctionCall: def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = {"type": "ephemeral"} + self.type = "function_call" + self.arguments = '{"location": "Boston"}' + self.call_id = "call_xyz789" + self.name = "get_weather" + self.id = "fc_12345" + self.status = "completed" - class MockPydanticV2Block: - def __init__(self): - self.type = "text" - self.text = "hello v2" - self.cache_control = {"type": "ephemeral"} - - def model_dump(self): - return {"type": self.type, "text": self.text, "cache_control": self.cache_control} - - class MockPydanticV1Block: - def __init__(self): - self.type = "text" - self.text = "hello v1" - self.cache_control = {"type": "ephemeral"} - - def dict(self): - return {"type": self.type, "text": self.text, "cache_control": self.cache_control} - - class MockBlockWithNoneCacheControl: - def __init__(self): - self.type = "text" - self.text = "hello none" - self.cache_control = None - - content = [MockContentBlock(), MockPydanticV2Block(), MockPydanticV1Block(), MockBlockWithNoneCacheControl()] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) - assert isinstance(result, list) - assert len(result) == 4 - assert result[0]["cache_control"] == {"type": "ephemeral"} - assert result[1]["cache_control"] == {"type": "ephemeral"} - assert result[2]["cache_control"] == {"type": "ephemeral"} - assert result[1]["text"] == "hello v2" - assert result[2]["text"] == "hello v1" - assert "cache_control" not in result[3] - - def test_is_cached_message_for_object_message_and_content_item(self): - """Test is_cached_message on custom objects / models.""" - from litellm.utils import is_cached_message - - # Test message level cache_control object - class MockCacheControl: - def __init__(self): - self.type = "ephemeral" - - class MockMessageLevelObj: - def __init__(self): - self.role = "system" - self.content = "hello" - self.cache_control = MockCacheControl() - - msg = MockMessageLevelObj() - assert is_cached_message(msg) is True - - # Test content level cache_control object - class MockContentItem: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = MockCacheControl() - - class MockContentLevelObj: - def __init__(self): - self.role = "system" - self.content = [MockContentItem()] - - msg = MockContentLevelObj() - assert is_cached_message(msg) is True - - def test_extract_ttl_from_cached_messages_for_object_models(self): - """Test extract_ttl_from_cached_messages with object-based messages and content items.""" - from litellm.llms.vertex_ai.context_caching.transformation import extract_ttl_from_cached_messages - - class MockCacheControl: - def __init__(self): - self.type = "ephemeral" - self.ttl = "3600s" - - class MockContentItem: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = MockCacheControl() - - class MockMessageObj: - def __init__(self): - self.role = "system" - self.content = [MockContentItem()] - - messages = [MockMessageObj()] - ttl = extract_ttl_from_cached_messages(messages) - assert ttl == "3600s" + item = MockFunctionCall() + messages = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( + item + ) + assert len(messages) == 1 + assert messages[0]["role"] == "assistant" + assert len(messages[0]["tool_calls"]) == 1 + assert messages[0]["tool_calls"][0]["id"] == "call_xyz789" + assert messages[0]["tool_calls"][0]["function"]["name"] == "get_weather" def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id():