Files
google--adk-python/tests/unittests/agents/test_gemini_context_cache_manager.py
T
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

1122 lines
40 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for GeminiContextCacheManager."""
import time
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from google.adk.agents.context_cache_config import ContextCacheConfig
from google.adk.models.cache_metadata import CacheMetadata
from google.adk.models.gemini_context_cache_manager import GeminiContextCacheManager
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import Client
from google.genai import types
class TestGeminiContextCacheManager:
"""Test suite for GeminiContextCacheManager."""
def setup_method(self):
"""Set up test fixtures."""
mock_client = AsyncMock(spec=Client)
self.manager = GeminiContextCacheManager(mock_client)
self.cache_config = ContextCacheConfig(
cache_intervals=10,
ttl_seconds=1800,
min_tokens=0, # Allow caching for tests
)
def create_llm_request(self, cache_metadata=None, contents_count=3):
"""Helper to create test LlmRequest."""
contents = []
for i in range(contents_count):
contents.append(
types.Content(
role="user", parts=[types.Part(text=f"Test message {i}")]
)
)
# Create tools for testing fingerprinting
tools = [
types.Tool(
function_declarations=[
types.FunctionDeclaration(
name="test_tool",
description="A test tool",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"param": types.Schema(type=types.Type.STRING)
},
),
)
]
)
]
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="AUTO")
)
return LlmRequest(
model="gemini-2.5-flash",
contents=contents,
config=types.GenerateContentConfig(
system_instruction="Test instruction",
tools=tools,
tool_config=tool_config,
),
cache_config=self.cache_config,
cache_metadata=cache_metadata,
)
def create_cache_metadata(
self, invocations_used=0, expired=False, contents_count=3
):
"""Helper to create test CacheMetadata."""
current_time = time.time()
expire_time = current_time - 300 if expired else current_time + 1800
return CacheMetadata(
cache_name="projects/test/locations/us-central1/cachedContents/test123",
expire_time=expire_time,
fingerprint="test_fingerprint",
invocations_used=invocations_used,
contents_count=contents_count,
created_at=current_time - 600,
)
def test_init(self):
"""Test manager initialization."""
mock_client = MagicMock(spec=Client)
manager = GeminiContextCacheManager(mock_client)
assert manager is not None
assert manager.genai_client == mock_client
async def test_handle_context_caching_no_existing_cache(self):
"""Test handling context caching with no existing cache returns fingerprint-only metadata."""
llm_request = self.create_llm_request(contents_count=5)
with patch.object(
self.manager, "_generate_cache_fingerprint", return_value="test_fp"
):
result = await self.manager.handle_context_caching(llm_request)
assert result is not None
# Should return fingerprint-only metadata (no active cache)
assert result.cache_name is None
assert result.expire_time is None
assert result.invocations_used is None
assert result.created_at is None
assert result.fingerprint == "test_fp"
assert result.contents_count == 0
# No cache should be created
self.manager.genai_client.aio.caches.create.assert_not_called()
async def test_handle_context_caching_valid_existing_cache(self):
"""Test handling context caching with valid existing cache."""
# Create request with existing valid cache
existing_cache = self.create_cache_metadata(invocations_used=5)
llm_request = self.create_llm_request(cache_metadata=existing_cache)
with patch.object(self.manager, "_is_cache_valid", return_value=True):
result = await self.manager.handle_context_caching(llm_request)
assert result is not None
# Verify that existing cache metadata is preserved (copied)
assert result.cache_name == existing_cache.cache_name
assert (
result.invocations_used == existing_cache.invocations_used
) # Should preserve original invocations_used
assert (
result.expire_time == existing_cache.expire_time
) # Should preserve original expire_time
assert (
result.fingerprint == existing_cache.fingerprint
) # Should preserve original fingerprint
assert (
result.created_at == existing_cache.created_at
) # Should preserve original created_at
# Verify it's a copy, not the same object
assert result is not existing_cache
# Should not create new cache
self.manager.genai_client.aio.caches.create.assert_not_called()
async def test_handle_context_caching_invalid_cache_fingerprint_match(self):
"""Test invalid cache with matching fingerprint creates new cache."""
# Setup mocks
mock_cached_content = AsyncMock()
mock_cached_content.name = (
"projects/test/locations/us-central1/cachedContents/new456"
)
self.manager.genai_client.aio.caches.create = AsyncMock(
return_value=mock_cached_content
)
# Create request with invalid existing cache
existing_cache = self.create_cache_metadata(
invocations_used=15
) # Exceeds cache_intervals
llm_request = self.create_llm_request(cache_metadata=existing_cache)
llm_request.cacheable_contents_token_count = (
5000 # Above Gemini's 4096 minimum for cache creation
)
with (
patch.object(self.manager, "_is_cache_valid", return_value=False),
patch.object(self.manager, "cleanup_cache") as mock_cleanup,
patch.object(
self.manager,
"_generate_cache_fingerprint",
return_value="test_fingerprint", # Match old fingerprint
),
):
result = await self.manager.handle_context_caching(llm_request)
assert result is not None
# Should create new cache when fingerprints match
assert (
result.cache_name
== "projects/test/locations/us-central1/cachedContents/new456"
)
mock_cleanup.assert_called_once_with(existing_cache.cache_name)
self.manager.genai_client.aio.caches.create.assert_called_once()
async def test_create_cache_gates_on_prefix_not_full_prompt(self):
"""Cache creation is gated on the cacheable prefix, not the full prompt.
Regression test for https://github.com/google/adk-python/issues/5847.
On a long conversation the previous-prompt token count
(``cacheable_contents_token_count``) can be well above Gemini's 4096-token
minimum while the cached prefix ``contents[:cache_contents_count]`` is far
below it. Creating a cache in that case makes ``caches.create`` fail with a
400 INVALID_ARGUMENT. The manager must skip cache creation instead.
"""
self.manager.genai_client.aio.caches.create = AsyncMock()
# A tiny cacheable prefix followed by a huge trailing user turn.
contents = [
types.Content(role="user", parts=[types.Part(text="Short prefix.")]),
types.Content(role="user", parts=[types.Part(text="word " * 100_000)]),
]
llm_request = LlmRequest(
model="gemini-2.5-flash",
contents=contents,
config=types.GenerateContentConfig(
system_instruction="You are a helpful assistant.",
),
cache_config=self.cache_config,
)
# Full previous prompt is large (clears the old, buggy gate)...
llm_request.cacheable_contents_token_count = 75000
# ...but only the tiny first content is cacheable.
result = await self.manager._create_new_cache_with_contents(
llm_request, cache_contents_count=1
)
assert result is None
self.manager.genai_client.aio.caches.create.assert_not_called()
async def test_handle_context_caching_invalid_cache_fingerprint_mismatch(
self,
):
"""Test invalid cache with mismatched fingerprint returns fingerprint-only metadata."""
# Create request with invalid existing cache
existing_cache = self.create_cache_metadata(
invocations_used=15, contents_count=3
) # Exceeds cache_intervals
llm_request = self.create_llm_request(
cache_metadata=existing_cache, contents_count=5
)
with (
patch.object(self.manager, "_is_cache_valid", return_value=False),
patch.object(self.manager, "cleanup_cache") as mock_cleanup,
patch.object(
self.manager,
"_generate_cache_fingerprint",
side_effect=["old_fp", "new_fp"], # Different fingerprints
),
):
result = await self.manager.handle_context_caching(llm_request)
assert result is not None
# Should return fingerprint-only metadata
assert result.cache_name is None
assert result.expire_time is None
assert result.invocations_used is None
assert result.created_at is None
assert result.fingerprint == "new_fp"
assert result.contents_count == 0
mock_cleanup.assert_called_once_with(existing_cache.cache_name)
self.manager.genai_client.aio.caches.create.assert_not_called()
async def test_is_cache_valid_fingerprint_mismatch(self):
"""Test cache validation with fingerprint mismatch."""
cache_metadata = self.create_cache_metadata()
llm_request = self.create_llm_request(cache_metadata=cache_metadata)
with patch.object(
self.manager,
"_generate_cache_fingerprint",
return_value="different_fingerprint",
):
result = await self.manager._is_cache_valid(llm_request)
assert result is False
async def test_is_cache_valid_expired_cache(self):
"""Test cache validation with expired cache."""
cache_metadata = self.create_cache_metadata(expired=True)
llm_request = self.create_llm_request(cache_metadata=cache_metadata)
with patch.object(
self.manager,
"_generate_cache_fingerprint",
return_value="test_fingerprint",
):
result = await self.manager._is_cache_valid(llm_request)
assert result is False
async def test_is_cache_valid_fingerprint_only_metadata(self):
"""Test cache validation with fingerprint-only metadata (no active cache)."""
# Create fingerprint-only metadata (cache_name is None)
cache_metadata = CacheMetadata(
fingerprint="test_fingerprint",
contents_count=5,
)
llm_request = self.create_llm_request(cache_metadata=cache_metadata)
result = await self.manager._is_cache_valid(llm_request)
assert (
result is False
) # Fingerprint-only metadata is not a valid active cache
async def test_is_cache_valid_cache_intervals_exceeded(self):
"""Test cache validation with max invocations exceeded."""
cache_metadata = self.create_cache_metadata(
invocations_used=15
) # Exceeds cache_intervals=10
llm_request = self.create_llm_request(cache_metadata=cache_metadata)
with patch.object(
self.manager,
"_generate_cache_fingerprint",
return_value="test_fingerprint",
):
result = await self.manager._is_cache_valid(llm_request)
assert result is False
async def test_is_cache_valid_all_checks_pass(self):
"""Test cache validation when all checks pass."""
cache_metadata = self.create_cache_metadata(
invocations_used=5
) # Within cache_intervals=10
llm_request = self.create_llm_request(cache_metadata=cache_metadata)
with patch.object(
self.manager,
"_generate_cache_fingerprint",
return_value="test_fingerprint",
):
result = await self.manager._is_cache_valid(llm_request)
assert result is True
async def test_cleanup_cache(self):
"""Test cache cleanup functionality."""
cache_name = "projects/test/locations/us-central1/cachedContents/test123"
await self.manager.cleanup_cache(cache_name)
self.manager.genai_client.aio.caches.delete.assert_called_once_with(
name=cache_name
)
def test_generate_cache_fingerprint(self):
"""Test cache fingerprint generation includes tools and tool_config."""
llm_request = self.create_llm_request()
cache_contents_count = 2 # Cache all but last content
fingerprint1 = self.manager._generate_cache_fingerprint(
llm_request, cache_contents_count
)
fingerprint2 = self.manager._generate_cache_fingerprint(
llm_request, cache_contents_count
)
# Same request should generate same fingerprint
assert fingerprint1 == fingerprint2
assert isinstance(fingerprint1, str)
assert len(fingerprint1) > 0
# Test that tool_config and tools are included in fingerprint
# Create request without tools/tool_config
llm_request_no_tools = LlmRequest(
model="gemini-2.5-flash",
contents=[types.Content(role="user", parts=[types.Part(text="Test")])],
config=types.GenerateContentConfig(
system_instruction="Test instruction"
),
cache_config=self.cache_config,
)
fingerprint_no_tools = self.manager._generate_cache_fingerprint(
llm_request_no_tools, cache_contents_count
)
# Should be different from request with tools
assert fingerprint1 != fingerprint_no_tools
def test_generate_cache_fingerprint_different_requests(self):
"""Test that different requests generate different fingerprints."""
llm_request1 = self.create_llm_request()
llm_request2 = LlmRequest(
model="gemini-2.5-flash",
contents=[
types.Content(
role="user", parts=[types.Part(text="Different message")]
)
],
config=types.GenerateContentConfig(
system_instruction="Different instruction"
),
cache_config=self.cache_config,
)
cache_contents_count = 2
fingerprint1 = self.manager._generate_cache_fingerprint(
llm_request1, cache_contents_count
)
fingerprint2 = self.manager._generate_cache_fingerprint(
llm_request2, cache_contents_count
)
assert fingerprint1 != fingerprint2
def test_generate_cache_fingerprint_tool_config_variations(self):
"""Test that different tool configs generate different fingerprints."""
# Request with AUTO mode
llm_request_auto = self.create_llm_request()
# Request with NONE mode
tool_config_none = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="NONE")
)
llm_request_none = LlmRequest(
model="gemini-2.5-flash",
contents=[types.Content(role="user", parts=[types.Part(text="Test")])],
config=types.GenerateContentConfig(
system_instruction="Test instruction",
tools=llm_request_auto.config.tools,
tool_config=tool_config_none,
),
cache_config=self.cache_config,
)
cache_contents_count = 2
fingerprint_auto = self.manager._generate_cache_fingerprint(
llm_request_auto, cache_contents_count
)
fingerprint_none = self.manager._generate_cache_fingerprint(
llm_request_none, cache_contents_count
)
assert fingerprint_auto != fingerprint_none
async def test_populate_cache_metadata_in_response_no_invocations_increment(
self,
):
"""Test that populate_cache_metadata_in_response doesn't increment invocations_used."""
# Create mock response with usage metadata
usage_metadata = MagicMock()
usage_metadata.cached_content_token_count = 800
usage_metadata.prompt_token_count = 1000
llm_response = MagicMock(spec=LlmResponse)
llm_response.usage_metadata = usage_metadata
cache_metadata = self.create_cache_metadata(invocations_used=3)
self.manager.populate_cache_metadata_in_response(
llm_response, cache_metadata
)
# Verify response metadata preserves the original invocations_used (no increment)
updated_metadata = llm_response.cache_metadata
assert (
updated_metadata.invocations_used == 3
) # Should preserve original value
assert updated_metadata.cache_name == cache_metadata.cache_name
assert updated_metadata.fingerprint == cache_metadata.fingerprint
assert updated_metadata.expire_time == cache_metadata.expire_time
assert updated_metadata.created_at == cache_metadata.created_at
async def test_populate_cache_metadata_no_usage_metadata(self):
"""Test populating cache metadata when no usage metadata."""
llm_response = MagicMock(spec=LlmResponse)
llm_response.usage_metadata = None
cache_metadata = self.create_cache_metadata(invocations_used=3)
self.manager.populate_cache_metadata_in_response(
llm_response, cache_metadata
)
# Should still create metadata even without usage info
updated_metadata = llm_response.cache_metadata
assert (
updated_metadata.invocations_used == 3
) # Should preserve original value
assert updated_metadata.cache_name == cache_metadata.cache_name
async def test_create_new_cache_with_proper_ttl(self):
"""Test that new cache is created with proper TTL."""
mock_cached_content = AsyncMock()
mock_cached_content.name = (
"projects/test/locations/us-central1/cachedContents/test123"
)
self.manager.genai_client.aio.caches.create = AsyncMock(
return_value=mock_cached_content
)
llm_request = self.create_llm_request()
cache_contents_count = max(0, len(llm_request.contents) - 1)
with patch.object(
self.manager, "_generate_cache_fingerprint", return_value="test_fp"
):
await self.manager._create_gemini_cache(llm_request, cache_contents_count)
# Verify cache creation call includes TTL
create_call = self.manager.genai_client.aio.caches.create.call_args
assert create_call is not None
cache_config = create_call[1]["config"]
assert cache_config.ttl == "1800s" # From cache_config
def test_all_but_last_content_caching(self):
"""Test that cache content counting works correctly."""
# Test with multiple contents
llm_request_multi = self.create_llm_request(contents_count=5)
# Test cache contents count calculation
cache_contents_count = max(0, len(llm_request_multi.contents) - 1)
assert cache_contents_count == 4 # 5 contents, so cache 4 contents
# Test with single content
llm_request_single = self.create_llm_request(contents_count=1)
single_cache_contents_count = max(0, len(llm_request_single.contents) - 1)
assert single_cache_contents_count == 0 # Single content, cache 0 contents
def test_edge_cases(self):
"""Test various edge cases."""
# Test with None cache_config
llm_request_no_config = LlmRequest(
model="gemini-2.5-flash",
contents=[types.Content(role="user", parts=[types.Part(text="Test")])],
config=types.GenerateContentConfig(system_instruction="Test"),
cache_config=None,
)
# Should handle gracefully
cache_contents_count = 2
fingerprint = self.manager._generate_cache_fingerprint(
llm_request_no_config, cache_contents_count
)
assert isinstance(fingerprint, str)
# Test with empty contents
llm_request_empty = LlmRequest(
model="gemini-2.5-flash",
contents=[],
config=types.GenerateContentConfig(system_instruction="Test"),
cache_config=self.cache_config,
)
empty_cache_contents_count = 0
fingerprint = self.manager._generate_cache_fingerprint(
llm_request_empty, empty_cache_contents_count
)
assert isinstance(fingerprint, str)
def test_parameter_types_enforcement(self):
"""Test that method calls with correct parameter types work properly."""
# Create proper objects
usage_metadata = MagicMock()
usage_metadata.cached_content_token_count = 500
usage_metadata.prompt_token_count = 1000
llm_response = MagicMock(spec=LlmResponse)
llm_response.usage_metadata = usage_metadata
cache_metadata = self.create_cache_metadata(invocations_used=3)
# This should work fine (correct types and order)
self.manager.populate_cache_metadata_in_response(
llm_response, cache_metadata
)
updated_metadata = llm_response.cache_metadata
assert updated_metadata.invocations_used == 3 # No increment in this method
# Document expected types for integration tests
assert isinstance(cache_metadata, CacheMetadata)
assert hasattr(
llm_response, "usage_metadata"
) # LlmResponse should have this
assert not hasattr(
cache_metadata, "usage_metadata"
) # CacheMetadata should NOT have this
def create_llm_request_with_token_count(
self, token_count=None, cache_metadata=None
):
"""Helper to create LlmRequest with cacheable_contents_token_count."""
llm_request = self.create_llm_request(cache_metadata=cache_metadata)
llm_request.cacheable_contents_token_count = token_count
return llm_request
async def test_cache_creation_with_sufficient_token_count(self):
"""Test that fingerprint-only metadata is returned even with sufficient tokens."""
# With new prefix matching logic, no cache is created without existing metadata
# Create request with sufficient token count
llm_request = self.create_llm_request_with_token_count(token_count=2048)
with patch.object(
self.manager, "_generate_cache_fingerprint", return_value="test_fp"
):
result = await self.manager.handle_context_caching(llm_request)
# Should return fingerprint-only metadata (no cache creation)
assert result is not None
assert result.cache_name is None # Fingerprint-only state
assert result.fingerprint == "test_fp"
assert result.contents_count == 0
self.manager.genai_client.aio.caches.create.assert_not_called()
async def test_cache_creation_with_insufficient_token_count(self):
"""Test that fingerprint-only metadata is returned even with insufficient tokens."""
# Set higher minimum token requirement
self.manager.cache_config = ContextCacheConfig(
cache_intervals=10,
ttl_seconds=1800,
min_tokens=2048,
)
# Create request with insufficient token count
llm_request = self.create_llm_request_with_token_count(token_count=1024)
llm_request.cache_config = self.manager.cache_config
with patch.object(
self.manager, "_generate_cache_fingerprint", return_value="test_fp"
):
result = await self.manager.handle_context_caching(llm_request)
# Should return fingerprint-only metadata
assert result is not None
assert result.cache_name is None
assert result.fingerprint == "test_fp"
self.manager.genai_client.aio.caches.create.assert_not_called()
async def test_cache_creation_without_token_count(self):
"""Test that fingerprint-only metadata is returned even without token count."""
# Create request without token count (initial request)
llm_request = self.create_llm_request_with_token_count(token_count=None)
with patch.object(
self.manager, "_generate_cache_fingerprint", return_value="test_fp"
):
result = await self.manager.handle_context_caching(llm_request)
# Should return fingerprint-only metadata
assert result is not None
assert result.cache_name is None
assert result.fingerprint == "test_fp"
self.manager.genai_client.aio.caches.create.assert_not_called()
async def test_fingerprint_stability_across_growing_contents_within_invocation(
self,
):
"""Fingerprint over a prefix stays stable as contents grow.
Within a single invocation, contents grow as tool calls happen:
[user_msg] -> [user_msg, model_tool_call, tool_response].
A fingerprint computed over contents[:1] should be the same
regardless of how many entries follow.
"""
user_msg = types.Content(
role="user", parts=[types.Part(text="What is the weather?")]
)
model_tool_call = types.Content(
role="model",
parts=[
types.Part(
function_call=types.FunctionCall(
name="get_weather", args={"city": "NYC"}
)
)
],
)
tool_response = types.Content(
role="user",
parts=[
types.Part(
function_response=types.FunctionResponse(
name="get_weather", response={"temp": "72F"}
)
)
],
)
# First LLM call: contents = [user_msg]
request_short = LlmRequest(
model="gemini-2.5-flash",
contents=[user_msg],
config=types.GenerateContentConfig(
system_instruction="You are a weather bot",
),
cache_config=self.cache_config,
)
fp_short = self.manager._generate_cache_fingerprint(request_short, 1)
# Second LLM call: contents grew to [user_msg, model, tool_resp]
request_long = LlmRequest(
model="gemini-2.5-flash",
contents=[user_msg, model_tool_call, tool_response],
config=types.GenerateContentConfig(
system_instruction="You are a weather bot",
),
cache_config=self.cache_config,
)
fp_long = self.manager._generate_cache_fingerprint(
request_long, 1 # Still fingerprint over first 1 content
)
# Fingerprints over the same prefix must be identical
assert fp_short == fp_long
async def test_fingerprint_preserved_on_cache_creation_failure(self):
"""When cache creation fails, contents_count is preserved.
When _create_new_cache_with_contents returns None (e.g., no token
count or below Gemini's 4096 minimum), the code preserves the
original contents_count so the fingerprint stays stable for
subsequent calls.
"""
# Simulate first call returning fingerprint-only metadata
# with contents_count=3 (the original prefix size)
first_metadata = CacheMetadata(
fingerprint="fp_for_3",
contents_count=3,
)
# Second call: contents grew to 5 entries but we carry forward
# old metadata with contents_count=3
llm_request = self.create_llm_request(
cache_metadata=first_metadata, contents_count=5
)
llm_request.cacheable_contents_token_count = None # No token count
with patch.object(
self.manager,
"_generate_cache_fingerprint",
side_effect=lambda _req, count: f"fp_for_{count}",
):
result = await self.manager.handle_context_caching(llm_request)
# Fix: contents_count and fingerprint are preserved from the
# original prefix, not reset to total array length.
assert result.cache_name is None
assert result.contents_count == 3
assert result.fingerprint == "fp_for_3"
async def test_multi_turn_fingerprint_stable_when_below_token_threshold(
self,
):
"""Fingerprint stays stable across turns when cache creation fails.
Simulates 3 invocations where cache creation always fails because
there is no token count. After the fix, contents_count is preserved
so the fingerprint remains stable across calls.
"""
fingerprints_seen = []
contents_counts_seen = []
metadata = None
for turn in range(3):
contents_count = 1 + turn * 2 # 1, 3, 5
llm_request = self.create_llm_request(
cache_metadata=metadata,
contents_count=contents_count,
)
llm_request.cacheable_contents_token_count = None
result = await self.manager.handle_context_caching(llm_request)
assert result is not None
assert result.cache_name is None
fingerprints_seen.append(result.fingerprint)
contents_counts_seen.append(result.contents_count)
metadata = result
# All contents in this helper are user-role messages, so there is no
# cacheable content prefix before the final user batch.
assert len(set(fingerprints_seen)) == 1
assert contents_counts_seen == [0, 0, 0]
async def test_contents_count_should_remain_stable_after_cache_creation_failure(
self,
):
"""Preserved contents_count keeps fingerprint stable on failure.
When cache creation fails, the returned metadata preserves the
original contents_count from the prefix, not reset to the total
number of contents. This keeps the fingerprint stable across
LLM calls within the same invocation.
"""
# First call: fingerprint-only metadata with contents_count=2
first_metadata = CacheMetadata(
fingerprint="original_fp",
contents_count=2,
)
# Second call: contents grew to 5 but old metadata says 2
llm_request = self.create_llm_request(
cache_metadata=first_metadata, contents_count=5
)
llm_request.cacheable_contents_token_count = None
# Use real fingerprint generation so the prefix fingerprint
# matches the old metadata's fingerprint
original_fp = self.manager._generate_cache_fingerprint(llm_request, 2)
first_metadata = CacheMetadata(
fingerprint=original_fp,
contents_count=2,
)
llm_request.cache_metadata = first_metadata
result = await self.manager.handle_context_caching(llm_request)
# EXPECTED: contents_count should stay at 2 (the prefix size)
assert result.contents_count == 2
# EXPECTED: fingerprint should match the original
assert result.fingerprint == original_fp
def test_multi_tool_call_single_invocation_contents_growth(self):
"""Test _find_count_of_contents_to_cache with tool call pattern.
Simulates realistic contents growth within a single invocation:
user_msg -> model_tool_call -> tool_response -> model_tool_call
-> tool_response -> final_model_response.
"""
user_msg = types.Content(
role="user",
parts=[types.Part(text="Find weather and news")],
)
model_tool_call_1 = types.Content(
role="model",
parts=[
types.Part(
function_call=types.FunctionCall(
name="get_weather", args={"city": "NYC"}
)
)
],
)
tool_response_1 = types.Content(
role="user",
parts=[
types.Part(
function_response=types.FunctionResponse(
name="get_weather", response={"temp": "72F"}
)
)
],
)
model_tool_call_2 = types.Content(
role="model",
parts=[
types.Part(
function_call=types.FunctionCall(
name="get_news", args={"topic": "tech"}
)
)
],
)
tool_response_2 = types.Content(
role="user",
parts=[
types.Part(
function_response=types.FunctionResponse(
name="get_news", response={"headline": "AI advances"}
)
)
],
)
final_model_response = types.Content(
role="model",
parts=[types.Part(text="Weather is 72F, news: AI advances")],
)
# Stage 1: Just user message
contents_1 = [user_msg]
count_1 = self.manager._find_count_of_contents_to_cache(contents_1)
assert count_1 == 0 # Only user content, nothing to cache before
# Stage 2: After first tool call cycle
contents_2 = [user_msg, model_tool_call_1, tool_response_1]
count_2 = self.manager._find_count_of_contents_to_cache(contents_2)
# Last user batch is tool_response_1 at index 2
# model_tool_call_1 at index 1 breaks the batch
# So cache everything before index 2 = 2 items
assert count_2 == 2
# Stage 3: After second tool call cycle
contents_3 = [
user_msg,
model_tool_call_1,
tool_response_1,
model_tool_call_2,
tool_response_2,
]
count_3 = self.manager._find_count_of_contents_to_cache(contents_3)
# Last user batch is tool_response_2 at index 4
# model_tool_call_2 at index 3 breaks the batch
# So cache everything before index 4 = 4 items
assert count_3 == 4
# Stage 4: After final model response
contents_4 = [
user_msg,
model_tool_call_1,
tool_response_1,
model_tool_call_2,
tool_response_2,
final_model_response,
]
count_4 = self.manager._find_count_of_contents_to_cache(contents_4)
# Last entry is model content, no trailing user batch
# All contents are before the (empty) last user batch
assert count_4 == 6
async def test_fingerprint_only_metadata_transitions_to_active_cache(
self,
):
"""Happy path: fingerprint-only transitions to active cache.
Simulates the full lifecycle across two LLM calls within the
same invocation using real fingerprint generation:
1. First call: no metadata -> returns fingerprint-only metadata
2. Second call: fingerprint matches, cache created successfully
"""
# --- First LLM call: no existing metadata ---
llm_request_1 = self.create_llm_request(contents_count=3)
result_1 = await self.manager.handle_context_caching(llm_request_1)
assert result_1 is not None
assert result_1.cache_name is None
assert result_1.contents_count == 0
# --- Second LLM call: carry forward fingerprint-only metadata ---
# Contents grew but we still have same prefix
llm_request_2 = self.create_llm_request(
cache_metadata=result_1, contents_count=5
)
# contents_count is 0 (all-user conversation), so the cached prefix is the
# system instruction + tools; use a large previous-prompt count so the
# estimated prefix clears Gemini's 4096-token minimum.
llm_request_2.cacheable_contents_token_count = 30000
# Verify prefix fingerprint matches (real implementation).
# The fingerprint-only metadata is "invalid" (no cache_name),
# so _is_cache_valid returns False. Then the code checks if
# the prefix fingerprint matches before attempting cache creation.
prefix_fp = self.manager._generate_cache_fingerprint(
llm_request_2, result_1.contents_count
)
assert prefix_fp == result_1.fingerprint, (
f"Prefix fingerprint mismatch: {prefix_fp!r} != "
f"{result_1.fingerprint!r}. "
"This indicates the contents_count was not preserved."
)
# Fingerprints match - cache creation should be attempted
mock_cached_content = AsyncMock()
mock_cached_content.name = (
"projects/test/locations/us-central1/cachedContents/new789"
)
self.manager.genai_client.aio.caches.create = AsyncMock(
return_value=mock_cached_content
)
result_2 = await self.manager.handle_context_caching(llm_request_2)
assert result_2 is not None
assert result_2.cache_name == (
"projects/test/locations/us-central1/cachedContents/new789"
)
assert result_2.contents_count == 0 # Preserved from prefix
assert result_2.invocations_used == 1
self.manager.genai_client.aio.caches.create.assert_called_once()
async def test_dynamic_instruction_does_not_break_initial_cache_fingerprint(
self,
):
"""Request-scoped dynamic instructions stay out of the cache prefix."""
dynamic_instruction = types.Content(
role="user", parts=[types.Part(text="Turn context: locale=en-US")]
)
user_msg = types.Content(
role="user", parts=[types.Part(text="what time is it?")]
)
model_tool_call = types.Content(
role="model",
parts=[
types.Part(
function_call=types.FunctionCall(name="get_time", args={})
)
],
)
tool_response = types.Content(
role="user",
parts=[
types.Part(
function_response=types.FunctionResponse(
name="get_time", response={"time": "12:00"}
)
)
],
)
request_1 = self.create_llm_request(contents_count=0)
request_1.contents = [dynamic_instruction, user_msg]
result_1 = await self.manager.handle_context_caching(request_1)
assert result_1 is not None
assert result_1.cache_name is None
assert result_1.contents_count == 0
request_2 = self.create_llm_request(
cache_metadata=result_1, contents_count=0
)
request_2.contents = [
user_msg,
model_tool_call,
dynamic_instruction,
tool_response,
]
# contents_count is 0, so the cached prefix is the system instruction +
# tools; use a large previous-prompt count so the estimated prefix clears
# Gemini's 4096-token minimum.
request_2.cacheable_contents_token_count = 30000
mock_cached_content = AsyncMock()
mock_cached_content.name = (
"projects/test/locations/us-central1/cachedContents/new789"
)
self.manager.genai_client.aio.caches.create = AsyncMock(
return_value=mock_cached_content
)
result_2 = await self.manager.handle_context_caching(request_2)
assert result_2 is not None
assert result_2.cache_name == (
"projects/test/locations/us-central1/cachedContents/new789"
)
assert result_2.contents_count == 0
assert result_2.invocations_used == 1
self.manager.genai_client.aio.caches.create.assert_called_once()
async def test_create_http_options_passthrough(self):
"""Test that create_http_options is passed through to cache creation config."""
mock_cached_content = AsyncMock()
mock_cached_content.name = (
"projects/test/locations/us-central1/cachedContents/test123"
)
self.manager.genai_client.aio.caches.create = AsyncMock(
return_value=mock_cached_content
)
# Create config with http_options (e.g. 10s timeout)
http_options = types.HttpOptions(timeout=10000)
cache_config_with_timeout = ContextCacheConfig(
cache_intervals=10,
ttl_seconds=1800,
min_tokens=0,
create_http_options=http_options,
)
llm_request = self.create_llm_request()
llm_request.cache_config = cache_config_with_timeout
cache_contents_count = max(0, len(llm_request.contents) - 1)
with patch.object(
self.manager, "_generate_cache_fingerprint", return_value="test_fp"
):
await self.manager._create_gemini_cache(llm_request, cache_contents_count)
# Verify cache creation call includes http_options
create_call = self.manager.genai_client.aio.caches.create.call_args
assert create_call is not None
cache_config = create_call[1]["config"]
assert cache_config.http_options is not None
assert cache_config.http_options.timeout == 10000
async def test_create_without_http_options(self):
"""Test that cache creation works without create_http_options."""
mock_cached_content = AsyncMock()
mock_cached_content.name = (
"projects/test/locations/us-central1/cachedContents/test123"
)
self.manager.genai_client.aio.caches.create = AsyncMock(
return_value=mock_cached_content
)
llm_request = self.create_llm_request()
cache_contents_count = max(0, len(llm_request.contents) - 1)
with patch.object(
self.manager, "_generate_cache_fingerprint", return_value="test_fp"
):
await self.manager._create_gemini_cache(llm_request, cache_contents_count)
# Verify cache creation call does not include http_options
create_call = self.manager.genai_client.aio.caches.create.call_args
assert create_call is not None
cache_config = create_call[1]["config"]
assert cache_config.http_options is None