mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
feat(caching): enhance Gemini context caching propagation and TTL normalization
This commit is contained in:
parent
f09b8ce34a
commit
5822aa87ee
5 changed files with 207 additions and 7 deletions
|
|
@ -305,11 +305,17 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
target: Dict or TypedDict to add cache_control to
|
||||
model: Model name to check if cache_control should be preserved
|
||||
"""
|
||||
from litellm.utils import _is_gemini_model
|
||||
|
||||
# TypedDict objects are dicts at runtime, so .get() works
|
||||
cache_control = (
|
||||
source.get("cache_control") if isinstance(source, dict) else getattr(source, "cache_control", None)
|
||||
)
|
||||
if cache_control and model and (self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model)):
|
||||
if cache_control and model and (
|
||||
self.is_anthropic_claude_model(model)
|
||||
or self.is_bedrock_arn_model(model)
|
||||
or _is_gemini_model(model, None)
|
||||
):
|
||||
# TypedDict objects support dict operations at runtime
|
||||
# Use type ignore consistent with codebase pattern (see anthropic/chat/transformation.py:432)
|
||||
if isinstance(target, dict):
|
||||
|
|
|
|||
|
|
@ -75,8 +75,9 @@ def extract_ttl_from_cached_messages(messages: List[AllMessageValues]) -> Option
|
|||
if isinstance(msg_cache_control, dict)
|
||||
else getattr(msg_cache_control, "ttl", None)
|
||||
)
|
||||
if ttl and _is_valid_ttl_format(ttl):
|
||||
return str(ttl)
|
||||
normalized = _normalize_ttl_to_seconds(ttl)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
|
||||
content = message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
|
||||
if not isinstance(content, list):
|
||||
|
|
@ -103,8 +104,9 @@ def extract_ttl_from_cached_messages(messages: List[AllMessageValues]) -> Option
|
|||
if isinstance(cache_control, dict)
|
||||
else getattr(cache_control, "ttl", None)
|
||||
)
|
||||
if ttl and _is_valid_ttl_format(ttl):
|
||||
return str(ttl)
|
||||
normalized = _normalize_ttl_to_seconds(ttl)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -138,6 +140,50 @@ def _is_valid_ttl_format(ttl: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _normalize_ttl_to_seconds(ttl: object) -> Optional[str]:
|
||||
"""
|
||||
Normalize a cache_control TTL into Gemini's "<seconds>s" format.
|
||||
|
||||
Accepts Gemini-native seconds (e.g. "3600s", "1.5s") and Anthropic-style
|
||||
minute/hour units (e.g. "5m", "1h") that Claude Code and the Anthropic
|
||||
/v1/messages spec use. Returns None for missing or unparseable values so
|
||||
Gemini falls back to its own default TTL.
|
||||
"""
|
||||
if not isinstance(ttl, str):
|
||||
return None
|
||||
|
||||
if _is_valid_ttl_format(ttl):
|
||||
return ttl
|
||||
|
||||
match = re.match(r"^([0-9]*\.?[0-9]+)(m|h)$", ttl)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
value = float(match.group(1))
|
||||
|
||||
if value <= 0:
|
||||
return None
|
||||
|
||||
seconds = value * (60 if match.group(2) == "m" else 3600)
|
||||
return f"{int(seconds)}s" if seconds.is_integer() else f"{seconds}s"
|
||||
|
||||
|
||||
def get_gemini_context_caching_min_tokens(model: str) -> int:
|
||||
"""
|
||||
Minimum input token count required to create an explicit Gemini context cache.
|
||||
|
||||
Gemini rejects a cachedContents create below a per-model floor with a 400, so
|
||||
the caller skips caching below this value. Figures from
|
||||
https://ai.google.dev/gemini-api/docs/caching (Gemini 2.5 -> 2048, Gemini 3.x
|
||||
-> 4096). Unknown Gemini models default to the highest known floor so a create
|
||||
is never attempted below the real minimum.
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
if "gemini-2.5" in model_lower or "gemini-2-5" in model_lower:
|
||||
return 2048
|
||||
return 4096
|
||||
|
||||
|
||||
def separate_cached_messages(
|
||||
messages: List[AllMessageValues],
|
||||
) -> Tuple[List[AllMessageValues], List[AllMessageValues]]:
|
||||
|
|
|
|||
|
|
@ -1388,14 +1388,17 @@ def test_should_add_cache_control_for_anthropic_model():
|
|||
|
||||
|
||||
def test_should_not_add_cache_control_for_non_anthropic_model():
|
||||
"""Should not add cache_control for non-Anthropic models."""
|
||||
"""Should not add cache_control for providers that reject an explicit cache_control field.
|
||||
|
||||
OpenAI/Azure do prompt caching implicitly and 400 on an unexpected
|
||||
cache_control field, so it must not be forwarded to them.
|
||||
"""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
cache_control = {"type": "ephemeral"}
|
||||
|
||||
for model in [
|
||||
CACHE_CONTROL_NON_ANTHROPIC_MODEL,
|
||||
"openai/gpt-4-turbo",
|
||||
"gemini-pro",
|
||||
]:
|
||||
target = {}
|
||||
adapter._add_cache_control_if_applicable(
|
||||
|
|
@ -1404,6 +1407,54 @@ def test_should_not_add_cache_control_for_non_anthropic_model():
|
|||
assert "cache_control" not in target
|
||||
|
||||
|
||||
def test_should_add_cache_control_for_gemini_model():
|
||||
"""Should add cache_control for Gemini / Vertex Gemini targets.
|
||||
|
||||
These consume anthropic-style cache_control blocks via the Gemini context
|
||||
caching path, so /v1/messages requests (e.g. Claude Code) routed to a
|
||||
Gemini model must keep it. Regression for the adapter dropping the field
|
||||
before it reaches the Gemini transformation.
|
||||
"""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
cache_control = {"type": "ephemeral", "ttl": "1h"}
|
||||
|
||||
for model in [
|
||||
"gemini-3.5-flash",
|
||||
"gemini/gemini-3.5-flash",
|
||||
"gemini-3.1-pro-preview",
|
||||
"vertex_ai/gemini-2.5-pro",
|
||||
]:
|
||||
target = {}
|
||||
adapter._add_cache_control_if_applicable(
|
||||
{"cache_control": cache_control}, target, model
|
||||
)
|
||||
assert target.get("cache_control") == cache_control
|
||||
|
||||
|
||||
def test_cache_control_preserved_in_text_content_for_gemini():
|
||||
"""cache_control must survive message translation for a Gemini target."""
|
||||
anthropic_messages = [
|
||||
AnthropicMessagesUserMessageParam(
|
||||
role="user",
|
||||
content=[
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is cached content",
|
||||
"cache_control": {"type": "ephemeral", "ttl": "1h"},
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_anthropic_messages_to_openai(
|
||||
messages=anthropic_messages, model="gemini/gemini-3.5-flash"
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"][0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
|
||||
|
||||
|
||||
def test_should_not_add_cache_control_when_none():
|
||||
"""Should not add cache_control when source has None or empty cache_control."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,33 @@
|
|||
import pytest
|
||||
from litellm.llms.vertex_ai.context_caching.transformation import (
|
||||
extract_ttl_from_cached_messages,
|
||||
get_gemini_context_caching_min_tokens,
|
||||
_is_valid_ttl_format,
|
||||
_normalize_ttl_to_seconds,
|
||||
transform_openai_messages_to_gemini_context_caching,
|
||||
)
|
||||
|
||||
|
||||
class TestGeminiContextCachingMinTokens:
|
||||
"""Per-model floor for explicit Gemini context cache creation."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, expected",
|
||||
[
|
||||
("gemini-2.5-flash", 2048),
|
||||
("gemini-2.5-pro", 2048),
|
||||
("gemini/gemini-2.5-pro", 2048),
|
||||
("vertex_ai/gemini-2.5-flash", 2048),
|
||||
("gemini-3.5-flash", 4096),
|
||||
("gemini-3.1-pro-preview", 4096),
|
||||
("gemini/gemini-3.5-flash", 4096),
|
||||
("gemini-1.5-pro", 4096),
|
||||
],
|
||||
)
|
||||
def test_min_tokens_by_model(self, model, expected):
|
||||
assert get_gemini_context_caching_min_tokens(model) == expected
|
||||
|
||||
|
||||
class TestTTLValidation:
|
||||
"""Test TTL format validation"""
|
||||
|
||||
|
|
@ -37,6 +59,65 @@ class TestTTLValidation:
|
|||
assert not _is_valid_ttl_format(ttl), f"TTL {ttl} should be invalid"
|
||||
|
||||
|
||||
class TestTTLNormalization:
|
||||
"""Normalization of anthropic-style TTL units into Gemini's seconds format."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ttl, expected",
|
||||
[
|
||||
("3600s", "3600s"),
|
||||
("1.5s", "1.5s"),
|
||||
("5m", "300s"),
|
||||
("90m", "5400s"),
|
||||
("1h", "3600s"),
|
||||
("2h", "7200s"),
|
||||
("0.5h", "1800s"),
|
||||
],
|
||||
)
|
||||
def test_normalizes_units_to_seconds(self, ttl, expected):
|
||||
assert _normalize_ttl_to_seconds(ttl) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ttl",
|
||||
["invalid", "", "0m", "0h", "-1h", "5d", "1 h", "m", None, 123, 3600],
|
||||
)
|
||||
def test_rejects_unparseable_ttl(self, ttl):
|
||||
assert _normalize_ttl_to_seconds(ttl) is None
|
||||
|
||||
def test_extract_ttl_normalizes_anthropic_hour_unit(self):
|
||||
"""Claude Code / Anthropic send "1h"; Gemini must receive "3600s"."""
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "cached",
|
||||
"cache_control": {"type": "ephemeral", "ttl": "1h"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
assert extract_ttl_from_cached_messages(messages) == "3600s"
|
||||
|
||||
def test_extract_ttl_normalizes_anthropic_minute_unit(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "cached",
|
||||
"cache_control": {"type": "ephemeral", "ttl": "5m"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
assert extract_ttl_from_cached_messages(messages) == "300s"
|
||||
|
||||
|
||||
class TestTTLExtraction:
|
||||
"""Test TTL extraction from cached messages"""
|
||||
|
||||
|
|
|
|||
|
|
@ -2673,6 +2673,22 @@ class TestCacheControlPreservation:
|
|||
assert messages[0]["tool_calls"][0]["id"] == "call_xyz789"
|
||||
assert messages[0]["tool_calls"][0]["function"]["name"] == "get_weather"
|
||||
|
||||
def test_is_input_item_object_type_checks(self):
|
||||
"""Test _is_input_item_tool_call_output and _is_input_item_function_call with custom objects."""
|
||||
class MockObj:
|
||||
def __init__(self, t):
|
||||
self.type = t
|
||||
|
||||
# Test tool call output
|
||||
assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(MockObj("function_call_output")) is True
|
||||
assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(MockObj("custom_tool_call_output")) is True
|
||||
assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(MockObj("text")) is False
|
||||
|
||||
# Test function call
|
||||
assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(MockObj("function_call")) is True
|
||||
assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(MockObj("custom_tool_call")) is True
|
||||
assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(MockObj("text")) is False
|
||||
|
||||
|
||||
def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id():
|
||||
"""Bedrock Mantle returns a non-unique, index-based ``call_id`` (``call_0`` that
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue