feat(caching): enhance Gemini context caching propagation and TTL normalization

This commit is contained in:
Elliott de Launay 2026-07-09 22:52:14 -04:00
parent f09b8ce34a
commit 5822aa87ee
5 changed files with 207 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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