mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
fix(responses): propagate message cache_control safely through objects and models
This commit is contained in:
parent
42a39dd819
commit
f09b8ce34a
5 changed files with 248 additions and 153 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue