fix(responses): propagate message cache_control safely through objects and models

This commit is contained in:
Elliott de Launay 2026-07-08 21:59:31 -04:00
parent 42a39dd819
commit f09b8ce34a
5 changed files with 248 additions and 153 deletions

View file

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

View file

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

View file

@ -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 = [

View file

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

View file

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