Skip to content

Commit 4d5438c

Browse files
GWealecopybara-github
authored andcommitted
fix: preserve cache fingerprint stability on creation failure
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 905207529
1 parent 533776e commit 4d5438c

2 files changed

Lines changed: 351 additions & 2 deletions

File tree

src/google/adk/models/gemini_context_cache_manager.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232

3333
logger = logging.getLogger("google_adk." + __name__)
3434

35+
# Gemini API requires a minimum of 4096 tokens for cached content.
36+
_GEMINI_MIN_CACHE_TOKENS = 4096
37+
3538
if TYPE_CHECKING:
3639
from google.genai import Client
3740

@@ -119,6 +122,19 @@ async def handle_context_caching(
119122
)
120123
return cache_metadata
121124

125+
# Cache creation failed (e.g., below Gemini's 4096 token minimum).
126+
# Preserve the original contents_count so the fingerprint stays
127+
# stable for subsequent calls instead of resetting to total.
128+
logger.debug(
129+
"Cache creation failed, preserving prefix fingerprint "
130+
"(contents_count=%d)",
131+
cache_contents_count,
132+
)
133+
return CacheMetadata(
134+
fingerprint=current_fingerprint,
135+
contents_count=cache_contents_count,
136+
)
137+
122138
# Fingerprints don't match - recalculate with total contents
123139
logger.debug(
124140
"Fingerprints don't match, returning fingerprint-only metadata"
@@ -304,6 +320,15 @@ async def _create_new_cache_with_contents(
304320
)
305321
return None
306322

323+
# Check client-side to avoid unnecessary API round-trips.
324+
if llm_request.cacheable_contents_token_count < _GEMINI_MIN_CACHE_TOKENS:
325+
logger.info(
326+
"Request below Gemini minimum cache size (%d < %d tokens)",
327+
llm_request.cacheable_contents_token_count,
328+
_GEMINI_MIN_CACHE_TOKENS,
329+
)
330+
return None
331+
307332
try:
308333
# Create cache using Gemini API directly
309334
return await self._create_gemini_cache(llm_request, cache_contents_count)

tests/unittests/agents/test_gemini_context_cache_manager.py

Lines changed: 326 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
from google.adk.models.llm_response import LlmResponse
2727
from google.genai import Client
2828
from google.genai import types
29-
import pytest
3029

3130

3231
class TestGeminiContextCacheManager:
@@ -179,7 +178,7 @@ async def test_handle_context_caching_invalid_cache_fingerprint_match(self):
179178
) # Exceeds cache_intervals
180179
llm_request = self.create_llm_request(cache_metadata=existing_cache)
181180
llm_request.cacheable_contents_token_count = (
182-
2048 # Add token count for cache creation
181+
5000 # Above Gemini's 4096 minimum for cache creation
183182
)
184183

185184
with (
@@ -627,3 +626,328 @@ async def test_cache_creation_without_token_count(self):
627626
assert result.cache_name is None
628627
assert result.fingerprint == "test_fp"
629628
self.manager.genai_client.aio.caches.create.assert_not_called()
629+
630+
async def test_fingerprint_stability_across_growing_contents_within_invocation(
631+
self,
632+
):
633+
"""Fingerprint over a prefix stays stable as contents grow.
634+
635+
Within a single invocation, contents grow as tool calls happen:
636+
[user_msg] -> [user_msg, model_tool_call, tool_response].
637+
A fingerprint computed over contents[:1] should be the same
638+
regardless of how many entries follow.
639+
"""
640+
user_msg = types.Content(
641+
role="user", parts=[types.Part(text="What is the weather?")]
642+
)
643+
model_tool_call = types.Content(
644+
role="model",
645+
parts=[
646+
types.Part(
647+
function_call=types.FunctionCall(
648+
name="get_weather", args={"city": "NYC"}
649+
)
650+
)
651+
],
652+
)
653+
tool_response = types.Content(
654+
role="user",
655+
parts=[
656+
types.Part(
657+
function_response=types.FunctionResponse(
658+
name="get_weather", response={"temp": "72F"}
659+
)
660+
)
661+
],
662+
)
663+
664+
# First LLM call: contents = [user_msg]
665+
request_short = LlmRequest(
666+
model="gemini-2.0-flash",
667+
contents=[user_msg],
668+
config=types.GenerateContentConfig(
669+
system_instruction="You are a weather bot",
670+
),
671+
cache_config=self.cache_config,
672+
)
673+
fp_short = self.manager._generate_cache_fingerprint(request_short, 1)
674+
675+
# Second LLM call: contents grew to [user_msg, model, tool_resp]
676+
request_long = LlmRequest(
677+
model="gemini-2.0-flash",
678+
contents=[user_msg, model_tool_call, tool_response],
679+
config=types.GenerateContentConfig(
680+
system_instruction="You are a weather bot",
681+
),
682+
cache_config=self.cache_config,
683+
)
684+
fp_long = self.manager._generate_cache_fingerprint(
685+
request_long, 1 # Still fingerprint over first 1 content
686+
)
687+
688+
# Fingerprints over the same prefix must be identical
689+
assert fp_short == fp_long
690+
691+
async def test_fingerprint_preserved_on_cache_creation_failure(self):
692+
"""When cache creation fails, contents_count is preserved.
693+
694+
When _create_new_cache_with_contents returns None (e.g., no token
695+
count or below Gemini's 4096 minimum), the code preserves the
696+
original contents_count so the fingerprint stays stable for
697+
subsequent calls.
698+
"""
699+
# Simulate first call returning fingerprint-only metadata
700+
# with contents_count=3 (the original prefix size)
701+
first_metadata = CacheMetadata(
702+
fingerprint="fp_for_3",
703+
contents_count=3,
704+
)
705+
706+
# Second call: contents grew to 5 entries but we carry forward
707+
# old metadata with contents_count=3
708+
llm_request = self.create_llm_request(
709+
cache_metadata=first_metadata, contents_count=5
710+
)
711+
llm_request.cacheable_contents_token_count = None # No token count
712+
713+
with patch.object(
714+
self.manager,
715+
"_generate_cache_fingerprint",
716+
side_effect=lambda _req, count: f"fp_for_{count}",
717+
):
718+
result = await self.manager.handle_context_caching(llm_request)
719+
720+
# Fix: contents_count and fingerprint are preserved from the
721+
# original prefix, not reset to total array length.
722+
assert result.cache_name is None
723+
assert result.contents_count == 3
724+
assert result.fingerprint == "fp_for_3"
725+
726+
async def test_multi_turn_fingerprint_stable_when_below_token_threshold(
727+
self,
728+
):
729+
"""Fingerprint stays stable across turns when cache creation fails.
730+
731+
Simulates 3 invocations where cache creation always fails because
732+
there is no token count. After the fix, contents_count is preserved
733+
so the fingerprint remains stable across calls.
734+
"""
735+
fingerprints_seen = []
736+
contents_counts_seen = []
737+
metadata = None
738+
739+
for turn in range(3):
740+
contents_count = 1 + turn * 2 # 1, 3, 5
741+
llm_request = self.create_llm_request(
742+
cache_metadata=metadata,
743+
contents_count=contents_count,
744+
)
745+
llm_request.cacheable_contents_token_count = None
746+
747+
result = await self.manager.handle_context_caching(llm_request)
748+
749+
assert result is not None
750+
assert result.cache_name is None
751+
fingerprints_seen.append(result.fingerprint)
752+
contents_counts_seen.append(result.contents_count)
753+
metadata = result
754+
755+
# First turn has no metadata, so uses total (1).
756+
# Subsequent turns preserve contents_count=1 from the prefix.
757+
# Fingerprint stays stable because contents[:1] is always the
758+
# same user message.
759+
assert len(set(fingerprints_seen)) == 1
760+
assert contents_counts_seen == [1, 1, 1]
761+
762+
async def test_contents_count_should_remain_stable_after_cache_creation_failure(
763+
self,
764+
):
765+
"""Preserved contents_count keeps fingerprint stable on failure.
766+
767+
When cache creation fails, the returned metadata preserves the
768+
original contents_count from the prefix, not reset to the total
769+
number of contents. This keeps the fingerprint stable across
770+
LLM calls within the same invocation.
771+
"""
772+
# First call: fingerprint-only metadata with contents_count=2
773+
first_metadata = CacheMetadata(
774+
fingerprint="original_fp",
775+
contents_count=2,
776+
)
777+
778+
# Second call: contents grew to 5 but old metadata says 2
779+
llm_request = self.create_llm_request(
780+
cache_metadata=first_metadata, contents_count=5
781+
)
782+
llm_request.cacheable_contents_token_count = None
783+
784+
# Use real fingerprint generation so the prefix fingerprint
785+
# matches the old metadata's fingerprint
786+
original_fp = self.manager._generate_cache_fingerprint(llm_request, 2)
787+
first_metadata = CacheMetadata(
788+
fingerprint=original_fp,
789+
contents_count=2,
790+
)
791+
llm_request.cache_metadata = first_metadata
792+
793+
result = await self.manager.handle_context_caching(llm_request)
794+
795+
# EXPECTED: contents_count should stay at 2 (the prefix size)
796+
assert result.contents_count == 2
797+
# EXPECTED: fingerprint should match the original
798+
assert result.fingerprint == original_fp
799+
800+
def test_multi_tool_call_single_invocation_contents_growth(self):
801+
"""Test _find_count_of_contents_to_cache with tool call pattern.
802+
803+
Simulates realistic contents growth within a single invocation:
804+
user_msg -> model_tool_call -> tool_response -> model_tool_call
805+
-> tool_response -> final_model_response.
806+
"""
807+
user_msg = types.Content(
808+
role="user",
809+
parts=[types.Part(text="Find weather and news")],
810+
)
811+
model_tool_call_1 = types.Content(
812+
role="model",
813+
parts=[
814+
types.Part(
815+
function_call=types.FunctionCall(
816+
name="get_weather", args={"city": "NYC"}
817+
)
818+
)
819+
],
820+
)
821+
tool_response_1 = types.Content(
822+
role="user",
823+
parts=[
824+
types.Part(
825+
function_response=types.FunctionResponse(
826+
name="get_weather", response={"temp": "72F"}
827+
)
828+
)
829+
],
830+
)
831+
model_tool_call_2 = types.Content(
832+
role="model",
833+
parts=[
834+
types.Part(
835+
function_call=types.FunctionCall(
836+
name="get_news", args={"topic": "tech"}
837+
)
838+
)
839+
],
840+
)
841+
tool_response_2 = types.Content(
842+
role="user",
843+
parts=[
844+
types.Part(
845+
function_response=types.FunctionResponse(
846+
name="get_news", response={"headline": "AI advances"}
847+
)
848+
)
849+
],
850+
)
851+
final_model_response = types.Content(
852+
role="model",
853+
parts=[types.Part(text="Weather is 72F, news: AI advances")],
854+
)
855+
856+
# Stage 1: Just user message
857+
contents_1 = [user_msg]
858+
count_1 = self.manager._find_count_of_contents_to_cache(contents_1)
859+
assert count_1 == 0 # Only user content, nothing to cache before
860+
861+
# Stage 2: After first tool call cycle
862+
contents_2 = [user_msg, model_tool_call_1, tool_response_1]
863+
count_2 = self.manager._find_count_of_contents_to_cache(contents_2)
864+
# Last user batch is tool_response_1 at index 2
865+
# model_tool_call_1 at index 1 breaks the batch
866+
# So cache everything before index 2 = 2 items
867+
assert count_2 == 2
868+
869+
# Stage 3: After second tool call cycle
870+
contents_3 = [
871+
user_msg,
872+
model_tool_call_1,
873+
tool_response_1,
874+
model_tool_call_2,
875+
tool_response_2,
876+
]
877+
count_3 = self.manager._find_count_of_contents_to_cache(contents_3)
878+
# Last user batch is tool_response_2 at index 4
879+
# model_tool_call_2 at index 3 breaks the batch
880+
# So cache everything before index 4 = 4 items
881+
assert count_3 == 4
882+
883+
# Stage 4: After final model response
884+
contents_4 = [
885+
user_msg,
886+
model_tool_call_1,
887+
tool_response_1,
888+
model_tool_call_2,
889+
tool_response_2,
890+
final_model_response,
891+
]
892+
count_4 = self.manager._find_count_of_contents_to_cache(contents_4)
893+
# Last entry is model content, no trailing user batch
894+
# All contents are before the (empty) last user batch
895+
assert count_4 == 6
896+
897+
async def test_fingerprint_only_metadata_transitions_to_active_cache(
898+
self,
899+
):
900+
"""Happy path: fingerprint-only transitions to active cache.
901+
902+
Simulates the full lifecycle across two LLM calls within the
903+
same invocation using real fingerprint generation:
904+
1. First call: no metadata -> returns fingerprint-only metadata
905+
2. Second call: fingerprint matches, cache created successfully
906+
"""
907+
# --- First LLM call: no existing metadata ---
908+
llm_request_1 = self.create_llm_request(contents_count=3)
909+
910+
result_1 = await self.manager.handle_context_caching(llm_request_1)
911+
912+
assert result_1 is not None
913+
assert result_1.cache_name is None
914+
assert result_1.contents_count == 3
915+
916+
# --- Second LLM call: carry forward fingerprint-only metadata ---
917+
# Contents grew but we still have same prefix
918+
llm_request_2 = self.create_llm_request(
919+
cache_metadata=result_1, contents_count=5
920+
)
921+
llm_request_2.cacheable_contents_token_count = 4096
922+
923+
# Verify prefix fingerprint matches (real implementation).
924+
# The fingerprint-only metadata is "invalid" (no cache_name),
925+
# so _is_cache_valid returns False. Then the code checks if
926+
# the prefix fingerprint matches before attempting cache creation.
927+
prefix_fp = self.manager._generate_cache_fingerprint(
928+
llm_request_2, result_1.contents_count
929+
)
930+
assert prefix_fp == result_1.fingerprint, (
931+
f"Prefix fingerprint mismatch: {prefix_fp!r} != "
932+
f"{result_1.fingerprint!r}. "
933+
"This indicates the contents_count was not preserved."
934+
)
935+
936+
# Fingerprints match - cache creation should be attempted
937+
mock_cached_content = AsyncMock()
938+
mock_cached_content.name = (
939+
"projects/test/locations/us-central1/cachedContents/new789"
940+
)
941+
self.manager.genai_client.aio.caches.create = AsyncMock(
942+
return_value=mock_cached_content
943+
)
944+
945+
result_2 = await self.manager.handle_context_caching(llm_request_2)
946+
947+
assert result_2 is not None
948+
assert result_2.cache_name == (
949+
"projects/test/locations/us-central1/cachedContents/new789"
950+
)
951+
assert result_2.contents_count == 3 # Preserved from prefix
952+
assert result_2.invocations_used == 1
953+
self.manager.genai_client.aio.caches.create.assert_called_once()

0 commit comments

Comments
 (0)