From 5822aa87eedbab9e017683c598145498d2001600 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Thu, 9 Jul 2026 22:52:14 -0400 Subject: [PATCH] feat(caching): enhance Gemini context caching propagation and TTL normalization --- .../adapters/transformation.py | 8 +- .../context_caching/transformation.py | 54 ++++++++++++- ...al_pass_through_adapters_transformation.py | 55 ++++++++++++- .../test_context_caching_ttl.py | 81 +++++++++++++++++++ .../test_litellm_completion_responses.py | 16 ++++ 5 files changed, 207 insertions(+), 7 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 4b6617fbeac..97f98cbea7e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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): diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index bc6ee47d3b8..b325c61aeca 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -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 "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]]: diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index dfe7e0c3a51..e86010a00f0 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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() 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 2b786820f8f..ec193f8b9d8 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 @@ -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""" 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 b34bdd812d6..62bc61c1a63 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 @@ -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