Skip to content

Commit 9be019e

Browse files
committed
fix(models): gate Gemini cache creation on cacheable prefix tokens
`_create_new_cache_with_contents` gated on `cacheable_contents_token_count` (the previous prompt's full `prompt_token_count`) but `caches.create` only receives the prefix `contents[:cache_contents_count]`. On long conversations the full count clears Gemini's 4096-token minimum while the cached prefix is below it, so `caches.create` fails with 400 INVALID_ARGUMENT. Gate on the prefix that is actually cached: `_estimate_cacheable_prefix_tokens` scales the accurate full-prompt count by the prefix's estimated share of the request (reusing `_estimate_request_tokens`, now accepting an optional `cache_contents_count`). When the prefix spans the whole request the factor is 1.0 and behavior is unchanged. Fixes #5847
1 parent f8e9195 commit 9be019e

2 files changed

Lines changed: 125 additions & 18 deletions

File tree

src/google/adk/models/gemini_context_cache_manager.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,20 @@ async def _create_new_cache_with_contents(
320320
)
321321
return None
322322

323-
# Check client-side to avoid unnecessary API round-trips.
324-
if llm_request.cacheable_contents_token_count < _GEMINI_MIN_CACHE_TOKENS:
323+
# `cacheable_contents_token_count` is the token count of the whole previous
324+
# prompt (system instruction + tools + every content). The cache, however,
325+
# only stores the prefix `contents[:cache_contents_count]` plus the system
326+
# instruction and tools (see `_create_gemini_cache`). On a long conversation
327+
# the full-prompt count can clear Gemini's minimum while the cached prefix is
328+
# far smaller, which makes `caches.create` fail with 400 INVALID_ARGUMENT.
329+
# Gate on the estimated prefix size so we never send a sub-minimum payload.
330+
cacheable_prefix_tokens = self._estimate_cacheable_prefix_tokens(
331+
llm_request, cache_contents_count
332+
)
333+
if cacheable_prefix_tokens < _GEMINI_MIN_CACHE_TOKENS:
325334
logger.info(
326-
"Request below Gemini minimum cache size (%d < %d tokens)",
327-
llm_request.cacheable_contents_token_count,
335+
"Cacheable prefix below Gemini minimum cache size (%d < %d tokens)",
336+
cacheable_prefix_tokens,
328337
_GEMINI_MIN_CACHE_TOKENS,
329338
)
330339
return None
@@ -336,13 +345,20 @@ async def _create_new_cache_with_contents(
336345
logger.warning("Failed to create cache: %s", e)
337346
return None
338347

339-
def _estimate_request_tokens(self, llm_request: LlmRequest) -> int:
340-
"""Estimate token count for the request.
348+
def _estimate_request_tokens(
349+
self,
350+
llm_request: LlmRequest,
351+
cache_contents_count: Optional[int] = None,
352+
) -> int:
353+
"""Estimate token count for the request (or its cacheable prefix).
341354
342355
This is a rough estimation based on content text length.
343356
344357
Args:
345358
llm_request: Request to estimate tokens for
359+
cache_contents_count: When provided, only the first
360+
``cache_contents_count`` contents are counted (the prefix that gets
361+
cached); the system instruction and tools are always included.
346362
347363
Returns:
348364
Estimated token count
@@ -360,15 +376,54 @@ def _estimate_request_tokens(self, llm_request: LlmRequest) -> int:
360376
tool_str = json.dumps(tool.model_dump())
361377
total_chars += len(tool_str)
362378

363-
# Contents
364-
for content in llm_request.contents:
379+
# Contents (optionally limited to the cacheable prefix)
380+
contents = llm_request.contents
381+
if cache_contents_count is not None:
382+
contents = contents[:cache_contents_count]
383+
for content in contents:
365384
for part in content.parts:
366385
if part.text:
367386
total_chars += len(part.text)
368387

369388
# Rough estimate: 4 characters per token
370389
return total_chars // 4
371390

391+
def _estimate_cacheable_prefix_tokens(
392+
self, llm_request: LlmRequest, cache_contents_count: int
393+
) -> int:
394+
"""Estimate the token count of the prefix that will actually be cached.
395+
396+
The only accurate token count available is
397+
``cacheable_contents_token_count``, which covers the entire previous prompt.
398+
Since the cache stores just the prefix ``contents[:cache_contents_count]``
399+
(plus system instruction and tools), we scale that accurate count by the
400+
prefix's estimated share of the request. When the prefix already spans the
401+
whole request the scale factor is 1 and the accurate count is returned
402+
unchanged.
403+
404+
Args:
405+
llm_request: Request to estimate the cacheable prefix tokens for
406+
cache_contents_count: Number of leading contents that get cached
407+
408+
Returns:
409+
Estimated token count of the cacheable prefix
410+
"""
411+
full_tokens = llm_request.cacheable_contents_token_count
412+
if not full_tokens:
413+
return 0
414+
415+
full_estimate = self._estimate_request_tokens(llm_request)
416+
if full_estimate <= 0:
417+
# No text to estimate from (e.g. non-text parts); fall back to the
418+
# accurate full count rather than incorrectly skipping the cache.
419+
return full_tokens
420+
421+
prefix_estimate = self._estimate_request_tokens(
422+
llm_request, cache_contents_count
423+
)
424+
ratio = min(1.0, prefix_estimate / full_estimate)
425+
return int(full_tokens * ratio)
426+
372427
async def _create_gemini_cache(
373428
self, llm_request: LlmRequest, cache_contents_count: int
374429
) -> CacheMetadata:

tests/unittests/agents/test_gemini_context_cache_manager.py

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,22 @@ def setup_method(self):
4141
min_tokens=0, # Allow caching for tests
4242
)
4343

44-
def create_llm_request(self, cache_metadata=None, contents_count=3):
45-
"""Helper to create test LlmRequest."""
44+
def create_llm_request(
45+
self, cache_metadata=None, contents_count=3, message_text=None
46+
):
47+
"""Helper to create test LlmRequest.
48+
49+
Args:
50+
cache_metadata: Optional existing cache metadata to attach.
51+
contents_count: Number of user contents to generate.
52+
message_text: When provided, every content uses this exact text instead
53+
of the default ``f"Test message {i}"``. Useful for building a
54+
cacheable prefix large enough to clear Gemini's token minimum.
55+
"""
4656
contents = []
4757
for i in range(contents_count):
48-
contents.append(
49-
types.Content(
50-
role="user", parts=[types.Part(text=f"Test message {i}")]
51-
)
52-
)
58+
text = message_text if message_text is not None else f"Test message {i}"
59+
contents.append(types.Content(role="user", parts=[types.Part(text=text)]))
5360

5461
# Create tools for testing fingerprinting
5562
tools = [
@@ -202,6 +209,43 @@ async def test_handle_context_caching_invalid_cache_fingerprint_match(self):
202209
mock_cleanup.assert_called_once_with(existing_cache.cache_name)
203210
self.manager.genai_client.aio.caches.create.assert_called_once()
204211

212+
async def test_create_cache_gates_on_prefix_not_full_prompt(self):
213+
"""Cache creation is gated on the cacheable prefix, not the full prompt.
214+
215+
Regression test for https://github.com/google/adk-python/issues/5847.
216+
217+
On a long conversation the previous-prompt token count
218+
(``cacheable_contents_token_count``) can be well above Gemini's 4096-token
219+
minimum while the cached prefix ``contents[:cache_contents_count]`` is far
220+
below it. Creating a cache in that case makes ``caches.create`` fail with a
221+
400 INVALID_ARGUMENT. The manager must skip cache creation instead.
222+
"""
223+
self.manager.genai_client.aio.caches.create = AsyncMock()
224+
225+
# A tiny cacheable prefix followed by a huge trailing user turn.
226+
contents = [
227+
types.Content(role="user", parts=[types.Part(text="Short prefix.")]),
228+
types.Content(role="user", parts=[types.Part(text="word " * 100_000)]),
229+
]
230+
llm_request = LlmRequest(
231+
model="gemini-2.5-flash",
232+
contents=contents,
233+
config=types.GenerateContentConfig(
234+
system_instruction="You are a helpful assistant.",
235+
),
236+
cache_config=self.cache_config,
237+
)
238+
# Full previous prompt is large (clears the old, buggy gate)...
239+
llm_request.cacheable_contents_token_count = 75000
240+
241+
# ...but only the tiny first content is cacheable.
242+
result = await self.manager._create_new_cache_with_contents(
243+
llm_request, cache_contents_count=1
244+
)
245+
246+
assert result is None
247+
self.manager.genai_client.aio.caches.create.assert_not_called()
248+
205249
async def test_handle_context_caching_invalid_cache_fingerprint_mismatch(
206250
self,
207251
):
@@ -904,8 +948,14 @@ async def test_fingerprint_only_metadata_transitions_to_active_cache(
904948
1. First call: no metadata -> returns fingerprint-only metadata
905949
2. Second call: fingerprint matches, cache created successfully
906950
"""
951+
# Use a prefix large enough to clear Gemini's token minimum, otherwise the
952+
# cached prefix (contents[:3]) would be correctly rejected as too small.
953+
big_text = "lorem ipsum " * 1000 # ~12k chars -> ~3k tokens per content
954+
907955
# --- First LLM call: no existing metadata ---
908-
llm_request_1 = self.create_llm_request(contents_count=3)
956+
llm_request_1 = self.create_llm_request(
957+
contents_count=3, message_text=big_text
958+
)
909959

910960
result_1 = await self.manager.handle_context_caching(llm_request_1)
911961

@@ -916,9 +966,11 @@ async def test_fingerprint_only_metadata_transitions_to_active_cache(
916966
# --- Second LLM call: carry forward fingerprint-only metadata ---
917967
# Contents grew but we still have same prefix
918968
llm_request_2 = self.create_llm_request(
919-
cache_metadata=result_1, contents_count=5
969+
cache_metadata=result_1, contents_count=5, message_text=big_text
920970
)
921-
llm_request_2.cacheable_contents_token_count = 4096
971+
# Full previous prompt is large; the cached prefix (first 3 contents) also
972+
# clears Gemini's 4096-token minimum.
973+
llm_request_2.cacheable_contents_token_count = 30000
922974

923975
# Verify prefix fingerprint matches (real implementation).
924976
# The fingerprint-only metadata is "invalid" (no cache_name),

0 commit comments

Comments
 (0)