Skip to content

Commit 8de4237

Browse files
GWealecopybara-github
authored andcommitted
fix: scope Gemini cache identity
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 947912687
1 parent 7006e33 commit 8de4237

2 files changed

Lines changed: 142 additions & 10 deletions

File tree

src/google/adk/models/gemini_context_cache_manager.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import json
2121
import logging
2222
import time
23+
from typing import Any
2324
from typing import Optional
2425
from typing import TYPE_CHECKING
2526

@@ -262,37 +263,56 @@ def _generate_cache_fingerprint(
262263
Returns:
263264
16-character hexadecimal fingerprint representing the cached state
264265
"""
265-
# Create fingerprint from system instruction, tools, tool_config, and first N contents
266-
fingerprint_data = {}
266+
# Explicit caches are model-specific, so the model is part of their
267+
# compatibility boundary along with the cached request fields.
268+
fingerprint_data: dict[str, Any] = {
269+
"model": llm_request.model,
270+
"cache_scope": self._cache_scope(),
271+
}
267272

268273
if llm_request.config and llm_request.config.system_instruction:
269-
fingerprint_data["system_instruction"] = (
270-
llm_request.config.system_instruction
271-
)
274+
try:
275+
fingerprint_data["system_instruction"] = llm_request.config.model_dump(
276+
mode="json", include={"system_instruction"}
277+
)["system_instruction"]
278+
except Exception: # pylint: disable=broad-except
279+
# Preserve support for SDK-accepted objects without a JSON serializer
280+
# (for example PIL images). Their string form is the best available
281+
# compatibility boundary.
282+
fingerprint_data["system_instruction"] = str(
283+
llm_request.config.system_instruction
284+
)
272285

273286
if llm_request.config and llm_request.config.tools:
274287
# Simplified: just dump types.Tool instances to JSON
275288
tools_data = []
276289
for tool in llm_request.config.tools:
277290
if isinstance(tool, types.Tool):
278-
tools_data.append(tool.model_dump())
291+
tools_data.append(tool.model_dump(mode="json"))
279292
fingerprint_data["tools"] = tools_data
280293

281294
if llm_request.config and llm_request.config.tool_config:
282295
fingerprint_data["tool_config"] = (
283-
llm_request.config.tool_config.model_dump()
296+
llm_request.config.tool_config.model_dump(mode="json")
284297
)
285298

286299
# Include first N contents in fingerprint
287300
if cache_contents_count > 0 and llm_request.contents:
288301
contents_data = []
289302
for i in range(min(cache_contents_count, len(llm_request.contents))):
290303
content = llm_request.contents[i]
291-
contents_data.append(content.model_dump())
304+
contents_data.append(content.model_dump(mode="json"))
292305
fingerprint_data["cached_contents"] = contents_data
293306

294-
# Generate hash using str() instead of json.dumps() to handle bytes
295-
fingerprint_str = str(fingerprint_data)
307+
# Canonical JSON makes semantically identical mappings produce the same
308+
# cache identity regardless of their insertion order. SDK model dumps in
309+
# JSON mode also encode binary parts deterministically.
310+
fingerprint_str = json.dumps(
311+
fingerprint_data,
312+
sort_keys=True,
313+
separators=(",", ":"),
314+
ensure_ascii=False,
315+
)
296316
return hashlib.sha256(fingerprint_str.encode()).hexdigest()[:16]
297317

298318
async def _create_new_cache_with_contents(
@@ -352,6 +372,23 @@ async def _create_new_cache_with_contents(
352372
logger.warning("Failed to create cache: %s", e)
353373
return None
354374

375+
def _cache_scope(self) -> dict[str, Any]:
376+
"""Return the backend namespace that owns explicit cache resources."""
377+
is_vertex = bool(self.genai_client.vertexai)
378+
scope: dict[str, Any] = {
379+
"backend": "vertex" if is_vertex else "gemini",
380+
}
381+
api_client = getattr(self.genai_client, "_api_client", None)
382+
if is_vertex and api_client is not None:
383+
scope["project"] = getattr(api_client, "project", None)
384+
scope["location"] = getattr(api_client, "location", None)
385+
386+
http_options = getattr(api_client, "_http_options", None)
387+
base_url = getattr(http_options, "base_url", None)
388+
if base_url:
389+
scope["base_url"] = base_url
390+
return scope
391+
355392
def _estimate_request_tokens(
356393
self,
357394
llm_request: LlmRequest,

tests/unittests/agents/test_gemini_context_cache_manager.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class TestGeminiContextCacheManager:
3434
def setup_method(self):
3535
"""Set up test fixtures."""
3636
mock_client = AsyncMock(spec=Client)
37+
mock_client.vertexai = False
3738
self.manager = GeminiContextCacheManager(mock_client)
3839
self.cache_config = ContextCacheConfig(
3940
cache_intervals=10,
@@ -202,6 +203,68 @@ async def test_handle_context_caching_invalid_cache_fingerprint_match(self):
202203
mock_cleanup.assert_called_once_with(existing_cache.cache_name)
203204
self.manager.genai_client.aio.caches.create.assert_called_once()
204205

206+
async def test_model_change_invalidates_active_cache(self):
207+
"""A cache created for one model is not reused by another model."""
208+
flash_request = self.create_llm_request(contents_count=0)
209+
flash_metadata = await self.manager.handle_context_caching(flash_request)
210+
assert flash_metadata is not None
211+
active_metadata = CacheMetadata(
212+
cache_name="cachedContents/flash-cache",
213+
expire_time=time.time() + 1_800,
214+
fingerprint=flash_metadata.fingerprint,
215+
invocations_used=1,
216+
contents_count=flash_metadata.contents_count,
217+
created_at=time.time(),
218+
)
219+
pro_request = self.create_llm_request(
220+
cache_metadata=active_metadata, contents_count=0
221+
)
222+
pro_request.model = "gemini-2.5-pro"
223+
self.manager.genai_client.aio.caches.delete = AsyncMock()
224+
225+
pro_metadata = await self.manager.handle_context_caching(pro_request)
226+
227+
assert pro_metadata is not None
228+
assert pro_metadata.cache_name is None
229+
assert pro_metadata.fingerprint != active_metadata.fingerprint
230+
self.manager.genai_client.aio.caches.delete.assert_awaited_once_with(
231+
name="cachedContents/flash-cache"
232+
)
233+
234+
async def test_backend_change_invalidates_active_cache(self):
235+
"""A Developer API cache is not reused by a Vertex client."""
236+
developer_request = self.create_llm_request(contents_count=0)
237+
developer_metadata = await self.manager.handle_context_caching(
238+
developer_request
239+
)
240+
assert developer_metadata is not None
241+
active_metadata = CacheMetadata(
242+
cache_name="cachedContents/developer-cache",
243+
expire_time=time.time() + 1_800,
244+
fingerprint=developer_metadata.fingerprint,
245+
invocations_used=1,
246+
contents_count=developer_metadata.contents_count,
247+
created_at=time.time(),
248+
)
249+
vertex_client = AsyncMock(spec=Client)
250+
vertex_client.vertexai = True
251+
vertex_client.aio.caches.delete = AsyncMock()
252+
vertex_manager = GeminiContextCacheManager(vertex_client)
253+
vertex_request = self.create_llm_request(
254+
cache_metadata=active_metadata, contents_count=0
255+
)
256+
257+
vertex_metadata = await vertex_manager.handle_context_caching(
258+
vertex_request
259+
)
260+
261+
assert vertex_metadata is not None
262+
assert vertex_metadata.cache_name is None
263+
assert vertex_metadata.fingerprint != active_metadata.fingerprint
264+
vertex_client.aio.caches.delete.assert_awaited_once_with(
265+
name="cachedContents/developer-cache"
266+
)
267+
205268
async def test_create_cache_gates_on_prefix_not_full_prompt(self):
206269
"""Cache creation is gated on the cacheable prefix, not the full prompt.
207270
@@ -421,6 +484,38 @@ def test_generate_cache_fingerprint_different_requests(self):
421484

422485
assert fingerprint1 != fingerprint2
423486

487+
def test_generate_cache_fingerprint_canonicalizes_mapping_order(self):
488+
"""Equivalent argument mappings do not cause an avoidable cache miss."""
489+
first_request = self.create_llm_request(contents_count=0)
490+
second_request = self.create_llm_request(contents_count=0)
491+
first_request.contents = [
492+
types.ModelContent(
493+
types.Part(
494+
function_call=types.FunctionCall(
495+
name="lookup", args={"first": 1, "second": 2}
496+
)
497+
)
498+
)
499+
]
500+
second_request.contents = [
501+
types.ModelContent(
502+
types.Part(
503+
function_call=types.FunctionCall(
504+
name="lookup", args={"second": 2, "first": 1}
505+
)
506+
)
507+
)
508+
]
509+
510+
first_fingerprint = self.manager._generate_cache_fingerprint(
511+
first_request, 1
512+
)
513+
second_fingerprint = self.manager._generate_cache_fingerprint(
514+
second_request, 1
515+
)
516+
517+
assert first_fingerprint == second_fingerprint
518+
424519
def test_generate_cache_fingerprint_tool_config_variations(self):
425520
"""Test that different tool configs generate different fingerprints."""
426521
# Request with AUTO mode

0 commit comments

Comments
 (0)