# 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. import logging from google.adk.agents.invocation_context import InvocationContext from google.adk.agents.sequential_agent import SequentialAgent from google.adk.models.llm_request import LlmRequest from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.adk.tools.tool_context import ToolContext from google.adk.tools.vertex_ai_search_tool import VertexAiSearchTool from google.adk.utils.model_name_utils import extract_model_name from google.adk.utils.model_name_utils import is_gemini_1_model from google.adk.utils.model_name_utils import is_gemini_model from google.genai import types import pytest VERTEX_SEARCH_TOOL_LOGGER_NAME = ( 'google_adk.google.adk.tools.vertex_ai_search_tool' ) async def _create_tool_context() -> ToolContext: session_service = InMemorySessionService() session = await session_service.create_session( app_name='test_app', user_id='test_user' ) agent = SequentialAgent(name='test_agent') invocation_context = InvocationContext( invocation_id='invocation_id', agent=agent, session=session, session_service=session_service, ) return ToolContext(invocation_context=invocation_context) class TestVertexAiSearchToolHelperFunctions: """Test the helper functions for model name extraction and validation.""" def test_extract_model_name_simple_model(self): """Test extraction of simple model names.""" assert extract_model_name('gemini-2.5-pro') == 'gemini-2.5-pro' assert extract_model_name('gemini-2.5-flash') == 'gemini-2.5-flash' assert extract_model_name('gemini-1.0-pro') == 'gemini-1.0-pro' assert extract_model_name('claude-3-sonnet') == 'claude-3-sonnet' def test_extract_model_name_path_based_model(self): """Test extraction of path-based model names.""" path_model = 'projects/265104255505/locations/us-central1/publishers/google/models/gemini-2.5-flash' assert extract_model_name(path_model) == 'gemini-2.5-flash' path_model_2 = 'projects/12345/locations/us-east1/publishers/google/models/gemini-1.5-pro-preview' assert extract_model_name(path_model_2) == 'gemini-1.5-pro-preview' def test_extract_model_name_invalid_path(self): """Test that invalid path formats return the original string.""" invalid_path = 'projects/invalid/path/format' assert extract_model_name(invalid_path) == invalid_path def test_is_gemini_model_simple_names(self): """Test Gemini model detection with simple model names.""" assert is_gemini_model('gemini-2.5-pro') is True assert is_gemini_model('gemini-2.5-flash') is True assert is_gemini_model('gemini-1.0-pro') is True assert is_gemini_model('claude-3-sonnet') is False assert is_gemini_model('gpt-4') is False assert is_gemini_model('gemini') is False # Must have dash after gemini def test_is_gemini_model_path_based_names(self): """Test Gemini model detection with path-based model names.""" gemini_path = 'projects/265104255505/locations/us-central1/publishers/google/models/gemini-2.5-flash' assert is_gemini_model(gemini_path) is True non_gemini_path = 'projects/265104255505/locations/us-central1/publishers/google/models/claude-3-sonnet' assert is_gemini_model(non_gemini_path) is False def test_is_gemini_1_model_simple_names(self): """Test Gemini 1.x model detection with simple model names.""" assert is_gemini_1_model('gemini-1.5-flash') is True assert is_gemini_1_model('gemini-1.0-pro') is True assert is_gemini_1_model('gemini-1.5-pro-preview') is True assert is_gemini_1_model('gemini-2.0-flash') is False assert is_gemini_1_model('gemini-2.5-flash') is False assert is_gemini_1_model('gemini-2.5-pro') is False assert is_gemini_1_model('gemini-10.0-pro') is False # Only 1.x versions assert is_gemini_1_model('claude-3-sonnet') is False def test_is_gemini_1_model_path_based_names(self): """Test Gemini 1.x model detection with path-based model names.""" gemini_1_path = 'projects/265104255505/locations/us-central1/publishers/google/models/gemini-1.5-flash' assert is_gemini_1_model(gemini_1_path) is True gemini_2_path = 'projects/265104255505/locations/us-central1/publishers/google/models/gemini-2.5-flash' assert is_gemini_1_model(gemini_2_path) is False def test_edge_cases(self): """Test edge cases for model name validation.""" # Test with empty string assert is_gemini_model('') is False assert is_gemini_1_model('') is False # Test with model names containing gemini but not starting with it assert is_gemini_model('my-gemini-model') is False assert is_gemini_1_model('my-gemini-1.5-model') is False # Test with model names that have gemini in the middle of the path tricky_path = 'projects/265104255505/locations/us-central1/publishers/gemini/models/claude-3-sonnet' assert is_gemini_model(tricky_path) is False class TestVertexAiSearchTool: """Test the VertexAiSearchTool class.""" def test_init_with_data_store_id(self): """Test initialization with data store ID.""" tool = VertexAiSearchTool(data_store_id='test_data_store') assert tool.data_store_id == 'test_data_store' assert tool.search_engine_id is None assert tool.data_store_specs is None def test_init_with_search_engine_id(self): """Test initialization with search engine ID.""" tool = VertexAiSearchTool(search_engine_id='test_search_engine') assert tool.search_engine_id == 'test_search_engine' assert tool.data_store_id is None assert tool.data_store_specs is None def test_init_with_engine_and_specs(self): """Test initialization with search engine ID and specs.""" specs = [ types.VertexAISearchDataStoreSpec( dataStore=( 'projects/p/locations/l/collections/c/dataStores/spec_store' ) ) ] engine_id = ( 'projects/p/locations/l/collections/c/engines/test_search_engine' ) tool = VertexAiSearchTool( search_engine_id=engine_id, data_store_specs=specs, ) assert tool.search_engine_id == engine_id assert tool.data_store_id is None assert tool.data_store_specs == specs def test_init_with_neither_raises_error(self): """Test that initialization without either ID raises ValueError.""" with pytest.raises( ValueError, match='Either data_store_id or search_engine_id must be specified', ): VertexAiSearchTool() def test_init_with_both_raises_error(self): """Test that initialization with both IDs raises ValueError.""" with pytest.raises( ValueError, match='Either data_store_id or search_engine_id must be specified', ): VertexAiSearchTool( data_store_id='test_data_store', search_engine_id='test_search_engine' ) def test_init_with_specs_but_no_engine_raises_error(self): """Test that specs without engine ID raises ValueError.""" specs = [ types.VertexAISearchDataStoreSpec( dataStore=( 'projects/p/locations/l/collections/c/dataStores/spec_store' ) ) ] with pytest.raises( ValueError, match=( 'search_engine_id must be specified if data_store_specs is' ' specified' ), ): VertexAiSearchTool( data_store_id='test_data_store', data_store_specs=specs ) @pytest.mark.asyncio async def test_process_llm_request_with_simple_gemini_model(self, caplog): """Test processing LLM request with simple Gemini model name.""" caplog.set_level(logging.DEBUG, logger=VERTEX_SEARCH_TOOL_LOGGER_NAME) tool = VertexAiSearchTool( data_store_id='test_data_store', filter='f', max_results=5 ) tool_context = await _create_tool_context() llm_request = LlmRequest( model='gemini-2.5-pro', config=types.GenerateContentConfig() ) await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) assert llm_request.config.tools is not None assert len(llm_request.config.tools) == 1 retrieval_tool = llm_request.config.tools[0] assert retrieval_tool.retrieval is not None assert retrieval_tool.retrieval.vertex_ai_search is not None assert ( retrieval_tool.retrieval.vertex_ai_search.datastore == 'test_data_store' ) assert retrieval_tool.retrieval.vertex_ai_search.engine is None assert retrieval_tool.retrieval.vertex_ai_search.filter == 'f' assert retrieval_tool.retrieval.vertex_ai_search.max_results == 5 # Verify debug log debug_records = [ r for r in caplog.records if 'Adding Vertex AI Search tool config' in r.message ] assert len(debug_records) == 1 log_message = debug_records[0].getMessage() assert 'datastore=test_data_store' in log_message assert 'engine=None' in log_message assert 'filter=f' in log_message assert 'max_results=5' in log_message assert 'data_store_specs=None' in log_message @pytest.mark.asyncio async def test_process_llm_request_with_path_based_gemini_model(self, caplog): """Test processing LLM request with path-based Gemini model name.""" caplog.set_level(logging.DEBUG, logger=VERTEX_SEARCH_TOOL_LOGGER_NAME) specs = [ types.VertexAISearchDataStoreSpec( dataStore=( 'projects/p/locations/l/collections/c/dataStores/spec_store' ) ) ] engine_id = 'projects/p/locations/l/collections/c/engines/test_engine' tool = VertexAiSearchTool( search_engine_id=engine_id, data_store_specs=specs, filter='f2', max_results=10, ) tool_context = await _create_tool_context() llm_request = LlmRequest( model=( 'projects/265104255505/locations/us-central1/publishers/' 'google/models/gemini-2.5-flash' ), config=types.GenerateContentConfig(), ) await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) assert llm_request.config.tools is not None assert len(llm_request.config.tools) == 1 retrieval_tool = llm_request.config.tools[0] assert retrieval_tool.retrieval is not None assert retrieval_tool.retrieval.vertex_ai_search is not None assert retrieval_tool.retrieval.vertex_ai_search.datastore is None assert retrieval_tool.retrieval.vertex_ai_search.engine == engine_id assert retrieval_tool.retrieval.vertex_ai_search.filter == 'f2' assert retrieval_tool.retrieval.vertex_ai_search.max_results == 10 assert retrieval_tool.retrieval.vertex_ai_search.data_store_specs == specs # Verify debug log debug_records = [ r for r in caplog.records if 'Adding Vertex AI Search tool config' in r.message ] assert len(debug_records) == 1 log_message = debug_records[0].getMessage() assert 'datastore=None' in log_message assert f'engine={engine_id}' in log_message assert 'filter=f2' in log_message assert 'max_results=10' in log_message assert 'data_store_specs=1 spec(s): [spec_store]' in log_message @pytest.mark.asyncio async def test_process_llm_request_with_gemini_1_and_other_tools_raises_error( self, ): """Test that Gemini 1.x with other tools raises ValueError.""" tool = VertexAiSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() existing_tool = types.Tool( function_declarations=[ types.FunctionDeclaration(name='test_function', description='test') ] ) llm_request = LlmRequest( model='gemini-1.5-flash', config=types.GenerateContentConfig(tools=[existing_tool]), ) with pytest.raises( ValueError, match=( 'Vertex AI search tool cannot be used with other tools in' ' Gemini 1.x' ), ): await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) @pytest.mark.asyncio async def test_process_llm_request_with_path_based_gemini_1_and_other_tools_raises_error( self, ): """Test that path-based Gemini 1.x with other tools raises ValueError.""" tool = VertexAiSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() existing_tool = types.Tool( function_declarations=[ types.FunctionDeclaration(name='test_function', description='test') ] ) llm_request = LlmRequest( model='projects/265104255505/locations/us-central1/publishers/google/models/gemini-1.5-pro-preview', config=types.GenerateContentConfig(tools=[existing_tool]), ) with pytest.raises( ValueError, match=( 'Vertex AI search tool cannot be used with other tools in' ' Gemini 1.x' ), ): await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) @pytest.mark.asyncio async def test_process_llm_request_with_non_gemini_model_raises_error(self): """Test that non-Gemini model raises ValueError.""" tool = VertexAiSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() llm_request = LlmRequest( model='claude-3-sonnet', config=types.GenerateContentConfig() ) with pytest.raises( ValueError, match=( 'Vertex AI search tool is not supported for model claude-3-sonnet' ), ): await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) @pytest.mark.asyncio async def test_process_llm_request_with_non_gemini_model_and_disabled_check( self, monkeypatch ): """Test non-Gemini model can pass when model-id check is disabled.""" monkeypatch.setenv('ADK_DISABLE_GEMINI_MODEL_ID_CHECK', 'true') tool = VertexAiSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() llm_request = LlmRequest( model='internal-model-v1', config=types.GenerateContentConfig() ) await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) assert llm_request.config.tools is not None assert len(llm_request.config.tools) == 1 retrieval_tool = llm_request.config.tools[0] assert retrieval_tool.retrieval is not None assert retrieval_tool.retrieval.vertex_ai_search is not None @pytest.mark.asyncio async def test_process_llm_request_with_path_based_non_gemini_model_raises_error( self, ): """Test that path-based non-Gemini model raises ValueError.""" tool = VertexAiSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() non_gemini_path = 'projects/265104255505/locations/us-central1/publishers/google/models/claude-3-sonnet' llm_request = LlmRequest( model=non_gemini_path, config=types.GenerateContentConfig() ) with pytest.raises( ValueError, match=( 'Vertex AI search tool is not supported for model' f' {non_gemini_path}' ), ): await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) @pytest.mark.asyncio async def test_process_llm_request_with_gemini_2_and_other_tools_succeeds( self, caplog ): """Test that Gemini 2.x with other tools succeeds.""" caplog.set_level(logging.DEBUG, logger=VERTEX_SEARCH_TOOL_LOGGER_NAME) tool = VertexAiSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() existing_tool = types.Tool( function_declarations=[ types.FunctionDeclaration(name='test_function', description='test') ] ) llm_request = LlmRequest( model='gemini-2.5-pro', config=types.GenerateContentConfig(tools=[existing_tool]), ) await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) # Should have both the existing tool and the new vertex AI search tool assert llm_request.config.tools is not None assert len(llm_request.config.tools) == 2 assert llm_request.config.tools[0] == existing_tool retrieval_tool = llm_request.config.tools[1] assert retrieval_tool.retrieval is not None assert retrieval_tool.retrieval.vertex_ai_search is not None assert ( retrieval_tool.retrieval.vertex_ai_search.datastore == 'test_data_store' ) # Verify debug log debug_records = [ r for r in caplog.records if 'Adding Vertex AI Search tool config' in r.message ] assert len(debug_records) == 1 log_message = debug_records[0].getMessage() assert 'datastore=test_data_store' in log_message assert 'engine=None' in log_message assert 'filter=None' in log_message assert 'max_results=None' in log_message assert 'data_store_specs=None' in log_message @pytest.mark.asyncio async def test_subclass_with_dynamic_filter(self): """Test subclassing to provide dynamic filter based on context.""" class DynamicFilterSearchTool(VertexAiSearchTool): """Custom search tool with dynamic filter.""" def _build_vertex_ai_search_config(self, ctx): user_id = ctx.state.get('user_id', 'default_user') return types.VertexAISearch( datastore=self.data_store_id, engine=self.search_engine_id, filter=f"user_id = '{user_id}'", max_results=self.max_results, ) tool = DynamicFilterSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() tool_context.state['user_id'] = 'test_user_123' llm_request = LlmRequest( model='gemini-2.5-pro', config=types.GenerateContentConfig() ) await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) assert llm_request.config.tools is not None assert len(llm_request.config.tools) == 1 retrieval_tool = llm_request.config.tools[0] assert retrieval_tool.retrieval is not None assert retrieval_tool.retrieval.vertex_ai_search is not None # Verify the filter was dynamically set assert ( retrieval_tool.retrieval.vertex_ai_search.filter == "user_id = 'test_user_123'" ) @pytest.mark.asyncio async def test_subclass_with_dynamic_max_results(self): """Test subclassing to provide dynamic max_results based on context.""" class DynamicMaxResultsSearchTool(VertexAiSearchTool): """Custom search tool with dynamic max_results.""" def _build_vertex_ai_search_config(self, ctx): # Use a larger max_results for premium users is_premium = ctx.state.get('is_premium', False) dynamic_max_results = 20 if is_premium else 5 return types.VertexAISearch( datastore=self.data_store_id, engine=self.search_engine_id, filter=self.filter, max_results=dynamic_max_results, ) tool = DynamicMaxResultsSearchTool( data_store_id='test_data_store', max_results=10 ) tool_context = await _create_tool_context() tool_context.state['is_premium'] = True llm_request = LlmRequest( model='gemini-2.5-pro', config=types.GenerateContentConfig() ) await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) retrieval_tool = llm_request.config.tools[0] # Verify max_results was dynamically set to premium value assert retrieval_tool.retrieval.vertex_ai_search.max_results == 20 @pytest.mark.asyncio async def test_subclass_receives_readonly_context(self): """Test that subclass receives the context correctly.""" received_contexts = [] class ContextCapturingSearchTool(VertexAiSearchTool): """Custom search tool that captures the context.""" def _build_vertex_ai_search_config(self, ctx): received_contexts.append(ctx) return types.VertexAISearch( datastore=self.data_store_id, engine=self.search_engine_id, filter=self.filter, max_results=self.max_results, ) tool = ContextCapturingSearchTool(data_store_id='test_data_store') tool_context = await _create_tool_context() llm_request = LlmRequest( model='gemini-2.5-pro', config=types.GenerateContentConfig() ) await tool.process_llm_request( tool_context=tool_context, llm_request=llm_request ) # Verify the context was passed to _build_vertex_ai_search_config assert len(received_contexts) == 1 assert received_contexts[0] is tool_context