From 70059df95f0bcec468f2fd98d9a5d46a635a4a19 Mon Sep 17 00:00:00 2001 From: farzad528 Date: Wed, 26 Nov 2025 00:29:58 +0000 Subject: [PATCH 1/4] refactor KB for index creation logic --- .../_search_provider.py | 138 +++++--- .../tests/test_search_provider.py | 313 ++++++++++-------- .../azure_ai_with_search_context_agentic.py | 79 +++-- 3 files changed, 319 insertions(+), 211 deletions(-) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py index 7e6ad8e621..6e7908cedf 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py @@ -129,6 +129,8 @@ class AzureAISearchSettings(AFBaseSettings): Can be set via environment variable AZURE_SEARCH_ENDPOINT. index_name: Name of the search index. Can be set via environment variable AZURE_SEARCH_INDEX_NAME. + knowledge_base_name: Name of an existing Knowledge Base (for agentic mode). + Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME. api_key: API key for authentication (optional, use managed identity if not provided). Can be set via environment variable AZURE_SEARCH_API_KEY. env_file_path: If provided, the .env settings are read from this file path location. @@ -158,6 +160,7 @@ class AzureAISearchSettings(AFBaseSettings): endpoint: str | None = None index_name: str | None = None + knowledge_base_name: str | None = None api_key: SecretStr | None = None @@ -239,7 +242,6 @@ def __init__( embedding_function: Callable[[str], Awaitable[list[float]]] | None = None, context_prompt: str | None = None, # Agentic mode parameters (Knowledge Base) - azure_ai_project_endpoint: str | None = None, azure_openai_resource_url: str | None = None, model_deployment_name: str | None = None, model_name: str | None = None, @@ -277,22 +279,18 @@ def __init__( Required if vector_field_name is specified and no server-side vectorization. context_prompt: Custom prompt to prepend to retrieved context. Default: "Use the following context to answer the question:" - azure_ai_project_endpoint: Azure AI Foundry project endpoint URL. - This is NOT the same as azure_openai_resource_url - the project endpoint is used - for Azure AI Foundry services, while the OpenAI endpoint is used by the Knowledge - Base to call the model for query planning. Required for agentic mode. - Example: "https://myproject.services.ai.azure.com/api/projects/myproject" azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base model calls. - This is the OpenAI endpoint used by the Knowledge Base to call the LLM for - query planning and reasoning. This is separate from the project endpoint because - the Knowledge Base directly calls Azure OpenAI for its internal operations. - Required for agentic mode. Example: "https://myresource.openai.azure.com" + Required when using agentic mode with index_name (to auto-create Knowledge Base). + Not required when using an existing knowledge_base_name. + Example: "https://myresource.openai.azure.com" model_deployment_name: Model deployment name in Azure OpenAI for Knowledge Base. - This is the deployment name the Knowledge Base uses to call the LLM. - Required for agentic mode. + Required when using agentic mode with index_name (to auto-create Knowledge Base). + Not required when using an existing knowledge_base_name. model_name: The underlying model name (e.g., "gpt-4o", "gpt-4o-mini"). If not provided, defaults to model_deployment_name. Used for Knowledge Base configuration. - knowledge_base_name: Name for the Knowledge Base. Required for agentic mode. + knowledge_base_name: Name of an existing Knowledge Base to use. + Required for agentic mode if not providing index_name. + Supports KBs with any source type (web, blob, index, etc.). retrieval_instructions: Custom instructions for the Knowledge Base's retrieval planning. Only used in agentic mode. azure_openai_api_key: Azure OpenAI API key for Knowledge Base to call the model. @@ -340,6 +338,7 @@ def __init__( settings = AzureAISearchSettings( endpoint=endpoint, index_name=index_name, + knowledge_base_name=knowledge_base_name, api_key=api_key if isinstance(api_key, str) else None, env_file_path=env_file_path, env_file_encoding=env_file_encoding, @@ -353,11 +352,36 @@ def __init__( "Azure AI Search endpoint is required. Set via 'endpoint' parameter " "or 'AZURE_SEARCH_ENDPOINT' environment variable." ) - if not settings.index_name: - raise ServiceInitializationError( - "Azure AI Search index name is required. Set via 'index_name' parameter " - "or 'AZURE_SEARCH_INDEX_NAME' environment variable." - ) + + # Validate index_name and knowledge_base_name based on mode + # Note: settings.* contains the resolved value (explicit param OR env var) + if mode == "semantic": + # Semantic mode: always requires index_name + if not settings.index_name: + raise ServiceInitializationError( + "Azure AI Search index name is required for semantic mode. " + "Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable." + ) + elif mode == "agentic": + # Agentic mode: requires exactly ONE of index_name or knowledge_base_name + if settings.index_name and settings.knowledge_base_name: + raise ServiceInitializationError( + "For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both. " + "Use 'index_name' to auto-create a Knowledge Base, or 'knowledge_base_name' to use an existing one." + ) + if not settings.index_name and not settings.knowledge_base_name: + raise ServiceInitializationError( + "For agentic mode, provide either 'index_name' (to auto-create Knowledge Base) " + "or 'knowledge_base_name' (to use existing Knowledge Base). " + "Set via parameters or environment variables " + "AZURE_SEARCH_INDEX_NAME / AZURE_SEARCH_KNOWLEDGE_BASE_NAME." + ) + # If using index_name to create KB, model config is required + if settings.index_name and not model_deployment_name: + raise ServiceInitializationError( + "model_deployment_name is required for agentic mode when creating Knowledge Base from index. " + "This is the Azure OpenAI deployment used by the Knowledge Base for query planning." + ) # Determine the credential to use resolved_credential: AzureKeyCredential | AsyncTokenCredential @@ -389,14 +413,27 @@ def __init__( self.azure_openai_deployment_name = model_deployment_name # If model_name not provided, default to deployment name self.model_name = model_name or model_deployment_name - self.knowledge_base_name = knowledge_base_name + # Use resolved KB name (from explicit param or env var) + self.knowledge_base_name = settings.knowledge_base_name self.retrieval_instructions = retrieval_instructions self.azure_openai_api_key = azure_openai_api_key - self.azure_ai_project_endpoint = azure_ai_project_endpoint self.knowledge_base_output_mode = knowledge_base_output_mode self.retrieval_reasoning_effort = retrieval_reasoning_effort self.agentic_message_history_count = agentic_message_history_count + # Determine if using existing Knowledge Base or auto-creating from index + # Since validation ensures exactly one of index_name/knowledge_base_name for agentic mode: + # - knowledge_base_name provided: use existing KB + # - index_name provided: auto-create KB from index + self._use_existing_knowledge_base = False + if mode == "agentic": + if settings.knowledge_base_name: + # Use existing KB directly (supports any source type: web, blob, index, etc.) + self._use_existing_knowledge_base = True + else: + # Auto-generate KB name from index name + self.knowledge_base_name = f"{settings.index_name}-kb" + # Auto-discover vector field if not specified self._auto_discovered_vector_field = False self._use_vectorizable_query = False # Will be set to True if server-side vectorization detected @@ -415,22 +452,23 @@ def __init__( "Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support. " "Please upgrade: pip install azure-search-documents>=11.7.0b1" ) - if not self.azure_openai_resource_url: + # Only require OpenAI resource URL if NOT using existing KB + # (existing KB already has its model configuration) + # Note: model_deployment_name is already validated at initialization + if not self._use_existing_knowledge_base and not self.azure_openai_resource_url: raise ValueError( - "azure_openai_resource_url is required for agentic mode. " + "azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index. " "This should be your Azure OpenAI endpoint (e.g., 'https://myresource.openai.azure.com')" ) - if not self.azure_openai_deployment_name: - raise ValueError("model_deployment_name is required for agentic mode") - if not knowledge_base_name: - raise ValueError("knowledge_base_name is required for agentic mode") - - # Create search client for semantic mode - self._search_client = SearchClient( - endpoint=self.endpoint, - index_name=self.index_name, - credential=self.credential, - ) + + # Create search client for semantic mode (only if index_name is available) + self._search_client: SearchClient | None = None + if self.index_name: + self._search_client = SearchClient( + endpoint=self.endpoint, + index_name=self.index_name, + credential=self.credential, + ) # Create index client and retrieval client for agentic mode (Knowledge Base) self._index_client: SearchIndexClient | None = None @@ -711,27 +749,45 @@ async def _semantic_search(self, query: str) -> list[str]: return formatted_results async def _ensure_knowledge_base(self) -> None: - """Ensure Knowledge Base and knowledge source are created. + """Ensure Knowledge Base and knowledge source are created or use existing KB. This method is idempotent - it will only create resources if they don't exist. Note: Azure SDK uses KnowledgeAgent classes internally, but the feature is marketed as "Knowledge Bases" in Azure AI Search. """ - if self._knowledge_base_initialized or not self._index_client: + if self._knowledge_base_initialized: return - # Runtime validation for agentic mode parameters + # Runtime validation if not self.knowledge_base_name: raise ValueError("knowledge_base_name is required for agentic mode") - if not self.azure_openai_resource_url: - raise ValueError("azure_openai_resource_url is required for agentic mode") - if not self.azure_openai_deployment_name: - raise ValueError("model_deployment_name is required for agentic mode") knowledge_base_name = self.knowledge_base_name - # Step 1: Create or get knowledge source + # Path 1: Use existing Knowledge Base directly (no index needed) + # This supports KB with any source type (web, blob, index, etc.) + if self._use_existing_knowledge_base: + # Just create the retrieval client - KB already exists with its own sources + if _agentic_retrieval_available and self._retrieval_client is None: + self._retrieval_client = KnowledgeBaseRetrievalClient( + endpoint=self.endpoint, + knowledge_base_name=knowledge_base_name, + credential=self.credential, + ) + self._knowledge_base_initialized = True + return + + # Path 2: Auto-create Knowledge Base from search index + # Requires index_client and OpenAI configuration + if not self._index_client: + raise ValueError("Index client is required when creating Knowledge Base from index") + if not self.azure_openai_resource_url: + raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index") + if not self.azure_openai_deployment_name: + raise ValueError("model_deployment_name is required when creating Knowledge Base from index") + + # Step 1: Create or get knowledge source from index knowledge_source_name = f"{self.index_name}-source" try: diff --git a/python/packages/azure-ai-search/tests/test_search_provider.py b/python/packages/azure-ai-search/tests/test_search_provider.py index 8d49c1532c..66ead79a6b 100644 --- a/python/packages/azure-ai-search/tests/test_search_provider.py +++ b/python/packages/azure-ai-search/tests/test_search_provider.py @@ -148,74 +148,105 @@ def test_init_semantic_mode_with_vector_field_requires_embedding_function(self) vector_field_name="embedding", ) - def test_init_agentic_mode_requires_azure_openai_resource_url(self) -> None: - """Test that agentic mode requires azure_openai_resource_url.""" - with pytest.raises(ValueError, match="azure_openai_resource_url"): + def test_init_agentic_mode_with_kb_only(self) -> None: + """Test agentic mode with existing knowledge_base_name (simplest path).""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) + assert provider.mode == "agentic" + assert provider.knowledge_base_name == "test-kb" + assert provider._use_existing_knowledge_base is True + + def test_init_agentic_mode_with_index_requires_model(self) -> None: + """Test that agentic mode with index_name requires model_deployment_name.""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with ( + patch.dict(os.environ, clean_env, clear=True), + pytest.raises(ServiceInitializationError, match="model_deployment_name"), + ): AzureAISearchContextProvider( endpoint="https://test.search.windows.net", index_name="test-index", api_key="test-key", mode="agentic", + env_file_path="", # Disable .env file loading ) - def test_init_agentic_mode_requires_model_deployment_name(self) -> None: - """Test that agentic mode requires model_deployment_name.""" - with pytest.raises(ValueError, match="model_deployment_name"): - AzureAISearchContextProvider( + def test_init_agentic_mode_with_index_and_model(self) -> None: + """Test agentic mode with index_name (auto-create KB path).""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + provider = AzureAISearchContextProvider( endpoint="https://test.search.windows.net", index_name="test-index", api_key="test-key", mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", + model_deployment_name="gpt-4o", azure_openai_resource_url="https://test.openai.azure.com", + env_file_path="", # Disable .env file loading ) - - def test_init_agentic_mode_requires_knowledge_base_name(self) -> None: - """Test that agentic mode requires knowledge_base_name.""" - with pytest.raises(ValueError, match="knowledge_base_name"): + assert provider.mode == "agentic" + assert provider.index_name == "test-index" + assert provider.knowledge_base_name == "test-index-kb" # Auto-generated + assert provider._use_existing_knowledge_base is False + + def test_init_agentic_mode_rejects_both_index_and_kb(self) -> None: + """Test that agentic mode rejects both index_name AND knowledge_base_name.""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with ( + patch.dict(os.environ, clean_env, clear=True), + pytest.raises(ServiceInitializationError, match="either 'index_name' OR 'knowledge_base_name', not both"), + ): AzureAISearchContextProvider( endpoint="https://test.search.windows.net", index_name="test-index", api_key="test-key", mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", + knowledge_base_name="test-kb", model_deployment_name="gpt-4o", azure_openai_resource_url="https://test.openai.azure.com", + env_file_path="", # Disable .env file loading ) - def test_init_agentic_mode_with_all_params(self) -> None: - """Test initialization with all agentic mode parameters.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="my-gpt-4o-deployment", - model_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) - assert provider.mode == "agentic" - assert provider.azure_ai_project_endpoint == "https://test.services.ai.azure.com" - assert provider.azure_openai_resource_url == "https://test.openai.azure.com" - assert provider.azure_openai_deployment_name == "my-gpt-4o-deployment" - assert provider.model_name == "gpt-4o" - assert provider.knowledge_base_name == "test-kb" + def test_init_agentic_mode_requires_index_or_kb(self) -> None: + """Test that agentic mode requires either index_name or knowledge_base_name.""" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with ( + patch.dict(os.environ, clean_env, clear=True), + pytest.raises(ServiceInitializationError, match="provide either 'index_name'.*or 'knowledge_base_name'"), + ): + AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + env_file_path="", # Disable .env file loading + ) def test_init_model_name_defaults_to_deployment_name(self) -> None: """Test that model_name defaults to deployment_name if not provided.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) - assert provider.model_name == "gpt-4o" + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + model_deployment_name="gpt-4o", + env_file_path="", # Disable .env file loading + ) + assert provider.model_name == "gpt-4o" def test_init_with_custom_context_prompt(self) -> None: """Test initialization with custom context prompt.""" @@ -335,7 +366,7 @@ class TestKnowledgeBaseSetup: async def test_ensure_knowledge_base_creates_when_not_exists( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: - """Test that Knowledge Base is created when it doesn't exist.""" + """Test that Knowledge Base is created when it doesn't exist (index_name path).""" # Setup mocks mock_index_client = AsyncMock() mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") @@ -347,57 +378,58 @@ async def test_ensure_knowledge_base_creates_when_not_exists( mock_search_client = AsyncMock() mock_search_class.return_value = mock_search_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - model_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use index_name path (auto-create KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + index_name="test-index", + api_key="test-key", + mode="agentic", + model_deployment_name="gpt-4o", + azure_openai_resource_url="https://test.openai.azure.com", + env_file_path="", # Disable .env file loading + ) - await provider._ensure_knowledge_base() + await provider._ensure_knowledge_base() - # Verify knowledge source was created - mock_index_client.create_knowledge_source.assert_called_once() - # Verify Knowledge Base was created - mock_index_client.create_or_update_knowledge_base.assert_called_once() + # Verify knowledge source was created + mock_index_client.create_knowledge_source.assert_called_once() + # Verify Knowledge Base was created + mock_index_client.create_or_update_knowledge_base.assert_called_once() @pytest.mark.asyncio @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_ensure_knowledge_base_skips_when_exists( + async def test_ensure_knowledge_base_skips_when_using_existing_kb( self, mock_search_class: MagicMock, mock_index_class: MagicMock ) -> None: - """Test that Knowledge Base setup is skipped when already exists.""" + """Test that KB setup is skipped when using existing knowledge_base_name.""" # Setup mocks mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.return_value = MagicMock() # Exists - mock_index_client.get_knowledge_base.return_value = MagicMock() # Exists mock_index_class.return_value = mock_index_client mock_search_client = AsyncMock() mock_search_class.return_value = mock_search_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) - await provider._ensure_knowledge_base() + await provider._ensure_knowledge_base() - # Verify nothing was created - mock_index_client.create_knowledge_source.assert_not_called() - mock_index_client.create_agent.assert_not_called() + # Verify nothing was created (using existing KB) + mock_index_client.create_knowledge_source.assert_not_called() + mock_index_client.create_or_update_knowledge_base.assert_not_called() class TestContextProviderLifecycle: @@ -437,21 +469,22 @@ async def test_context_manager_agentic_cleanup( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - async with AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) as provider: - # Simulate retrieval client being created - provider._retrieval_client = mock_retrieval_client + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + async with AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) as provider: + # Simulate retrieval client being created + provider._retrieval_client = mock_retrieval_client - # Verify cleanup was called - mock_retrieval_client.close.assert_called_once() + # Verify cleanup was called + mock_retrieval_client.close.assert_called_once() def test_string_api_key_conversion(self) -> None: """Test that string api_key is converted to AzureKeyCredential.""" @@ -579,9 +612,6 @@ async def test_agentic_search_basic( # Setup index client mock mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_knowledge_source = AsyncMock() - mock_index_client.create_or_update_knowledge_base = AsyncMock() mock_index_class.return_value = mock_index_client # Setup retrieval client mock with response @@ -603,22 +633,23 @@ async def test_agentic_search_basic( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) - context = await provider.invoking(sample_messages) + context = await provider.invoking(sample_messages) - assert isinstance(context, Context) - # Should have at least the prompt message - assert len(context.messages) >= 1 + assert isinstance(context, Context) + # Should have at least the prompt message + assert len(context.messages) >= 1 @pytest.mark.asyncio @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") @@ -637,9 +668,6 @@ async def test_agentic_search_no_results( mock_search_class.return_value = mock_search_client mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_knowledge_source = AsyncMock() - mock_index_client.create_or_update_knowledge_base = AsyncMock() mock_index_class.return_value = mock_index_client # Empty response @@ -650,22 +678,23 @@ async def test_agentic_search_no_results( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + env_file_path="", # Disable .env file loading + ) - context = await provider.invoking(sample_messages) + context = await provider.invoking(sample_messages) - assert isinstance(context, Context) - # Should have fallback message - assert len(context.messages) >= 1 + assert isinstance(context, Context) + # Should have fallback message + assert len(context.messages) >= 1 @pytest.mark.asyncio @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") @@ -684,9 +713,6 @@ async def test_agentic_search_with_medium_reasoning( mock_search_class.return_value = mock_search_client mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_knowledge_source = AsyncMock() - mock_index_client.create_or_update_knowledge_base = AsyncMock() mock_index_class.return_value = mock_index_client mock_retrieval_client = AsyncMock() @@ -706,22 +732,23 @@ async def test_agentic_search_with_medium_reasoning( mock_retrieval_client.close = AsyncMock() mock_retrieval_class.return_value = mock_retrieval_client - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - azure_ai_project_endpoint="https://test.services.ai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="test-kb", - azure_openai_resource_url="https://test.openai.azure.com", - retrieval_reasoning_effort="medium", # Test medium reasoning - ) + # Clear environment to ensure no env vars interfere + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} + with patch.dict(os.environ, clean_env, clear=True): + # Use knowledge_base_name path (existing KB) + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + api_key="test-key", + mode="agentic", + knowledge_base_name="test-kb", + retrieval_reasoning_effort="medium", # Test medium reasoning + env_file_path="", # Disable .env file loading + ) - context = await provider.invoking(sample_messages) + context = await provider.invoking(sample_messages) - assert isinstance(context, Context) - assert len(context.messages) >= 1 + assert isinstance(context, Context) + assert len(context.messages) >= 1 class TestVectorFieldAutoDiscovery: diff --git a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py b/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py index 7531f9977b..f8e1123b8f 100644 --- a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py +++ b/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py @@ -25,18 +25,22 @@ For simple queries where speed is critical, use semantic mode instead (see azure_ai_with_search_context_semantic.py). Prerequisites: -1. An Azure AI Search service with a search index +1. An Azure AI Search service 2. An Azure AI Foundry project with a model deployment -3. An Azure OpenAI resource (for Knowledge Base model calls) -4. Set the following environment variables: +3. Either an existing Knowledge Base OR a search index (to auto-create a KB) + +Environment variables: - AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint - - AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses DefaultAzureCredential for Entra ID - - AZURE_SEARCH_INDEX_NAME: Your search index name + - AZURE_SEARCH_API_KEY: (Optional) API key - if not provided, uses DefaultAzureCredential - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") + +For using an existing Knowledge Base (recommended): - AZURE_SEARCH_KNOWLEDGE_BASE_NAME: Your Knowledge Base name - - AZURE_OPENAI_RESOURCE_URL: Your Azure OpenAI resource URL (e.g., "https://myresource.openai.azure.com") - Note: This is different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs the OpenAI endpoint for model calls + +For auto-creating a Knowledge Base from an index: + - AZURE_SEARCH_INDEX_NAME: Your search index name + - AZURE_OPENAI_RESOURCE_URL: Azure OpenAI resource URL (e.g., "https://myresource.openai.azure.com") """ # Sample queries to demonstrate agentic RAG @@ -53,31 +57,52 @@ async def main() -> None: # Get configuration from environment search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] search_key = os.environ.get("AZURE_SEARCH_API_KEY") - index_name = os.environ["AZURE_SEARCH_INDEX_NAME"] project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") - knowledge_base_name = os.environ["AZURE_SEARCH_KNOWLEDGE_BASE_NAME"] - azure_openai_resource_url = os.environ["AZURE_OPENAI_RESOURCE_URL"] + + # Agentic mode requires exactly ONE of: knowledge_base_name OR index_name + # Option 1: Use existing Knowledge Base (recommended) + knowledge_base_name = os.environ.get("AZURE_SEARCH_KNOWLEDGE_BASE_NAME") + # Option 2: Auto-create KB from index (requires azure_openai_resource_url) + index_name = os.environ.get("AZURE_SEARCH_INDEX_NAME") + azure_openai_resource_url = os.environ.get("AZURE_OPENAI_RESOURCE_URL") # Create Azure AI Search context provider with agentic mode (recommended for accuracy) print("Using AGENTIC mode (Knowledge Bases with query planning, recommended)\n") - print("â„šī¸ This mode is slightly slower but provides more accurate results.\n") - search_provider = AzureAISearchContextProvider( - endpoint=search_endpoint, - index_name=index_name, - api_key=search_key, # Use api_key for API key auth, or credential for managed identity - credential=AzureCliCredential() if not search_key else None, - mode="agentic", # Advanced mode for multi-hop reasoning - # Agentic mode configuration - azure_ai_project_endpoint=project_endpoint, - azure_openai_resource_url=azure_openai_resource_url, - model_deployment_name=model_deployment, - knowledge_base_name=knowledge_base_name, - # Optional: Configure retrieval behavior - knowledge_base_output_mode="extractive_data", # or "answer_synthesis" - retrieval_reasoning_effort="minimal", # or "medium", "low" - top_k=3, # Note: In agentic mode, the server-side Knowledge Base determines final retrieval - ) + print("This mode is slightly slower but provides more accurate results.\n") + + # Configure based on whether using existing KB or auto-creating from index + if knowledge_base_name: + # Use existing Knowledge Base - simplest approach + search_provider = AzureAISearchContextProvider( + endpoint=search_endpoint, + api_key=search_key, + credential=AzureCliCredential() if not search_key else None, + mode="agentic", + knowledge_base_name=knowledge_base_name, + # Optional: Configure retrieval behavior + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" + retrieval_reasoning_effort="minimal", # or "medium", "low" + ) + else: + # Auto-create Knowledge Base from index + if not index_name: + raise ValueError("Set AZURE_SEARCH_KNOWLEDGE_BASE_NAME or AZURE_SEARCH_INDEX_NAME") + if not azure_openai_resource_url: + raise ValueError("AZURE_OPENAI_RESOURCE_URL required when using index_name") + search_provider = AzureAISearchContextProvider( + endpoint=search_endpoint, + index_name=index_name, + api_key=search_key, + credential=AzureCliCredential() if not search_key else None, + mode="agentic", + azure_openai_resource_url=azure_openai_resource_url, + model_deployment_name=model_deployment, + # Optional: Configure retrieval behavior + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" + retrieval_reasoning_effort="minimal", # or "medium", "low" + top_k=3, + ) # Create agent with search context provider async with ( From 05c5d631c3379429d6acba87e24afeb13d0151a1 Mon Sep 17 00:00:00 2001 From: farzad528 Date: Wed, 26 Nov 2025 01:30:17 +0000 Subject: [PATCH 2/4] add user agent header for tracking --- .../_search_provider.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py index 6e7908cedf..5854684d33 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable, MutableSequence from typing import TYPE_CHECKING, Any, ClassVar, Literal -from agent_framework import ChatMessage, Context, ContextProvider, Role +from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMessage, Context, ContextProvider, Role from agent_framework._logging import get_logger from agent_framework._pydantic import AFBaseSettings from agent_framework.exceptions import ServiceInitializationError @@ -468,6 +468,7 @@ def __init__( endpoint=self.endpoint, index_name=self.index_name, credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, ) # Create index client and retrieval client for agentic mode (Knowledge Base) @@ -477,6 +478,7 @@ def __init__( self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, ) # Retrieval client will be created after Knowledge Base initialization @@ -612,7 +614,11 @@ async def _auto_discover_vector_field(self) -> None: try: # Use existing index client or create temporary one if not self._index_client: - self._index_client = SearchIndexClient(endpoint=self.endpoint, credential=self.credential) + self._index_client = SearchIndexClient( + endpoint=self.endpoint, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) index_client = self._index_client # Get index schema @@ -774,6 +780,7 @@ async def _ensure_knowledge_base(self) -> None: endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, ) self._knowledge_base_initialized = True return @@ -850,6 +857,7 @@ async def _ensure_knowledge_base(self) -> None: endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, ) async def _agentic_search(self, messages: list[ChatMessage]) -> list[str]: From 21eab77f80bebc7f94c9f062b3d38034f9206ebc Mon Sep 17 00:00:00 2001 From: Farzad Date: Tue, 2 Dec 2025 07:52:23 -0600 Subject: [PATCH 3/4] fix mypy issues --- .../_search_provider.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py index 5854684d33..a63ad1deb2 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py @@ -621,7 +621,12 @@ async def _auto_discover_vector_field(self) -> None: ) index_client = self._index_client - # Get index schema + # Get index schema (index_name is guaranteed to be set for semantic mode) + if not self.index_name: + logger.warning("Cannot auto-discover vector field: index_name is not set.") + self._auto_discovered_vector_field = True + return + index = await index_client.get_index(self.index_name) # Step 1: Find all vector fields @@ -738,7 +743,10 @@ async def _semantic_search(self, query: str) -> list[str]: search_params["semantic_configuration_name"] = self.semantic_configuration_name search_params["query_caption"] = QueryCaptionType.EXTRACTIVE - # Execute search + # Execute search (search client is guaranteed to exist for semantic mode) + if not self._search_client: + raise RuntimeError("Search client is not initialized. This should not happen in semantic mode.") + results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType] # Format results with citations @@ -793,6 +801,8 @@ async def _ensure_knowledge_base(self) -> None: raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index") if not self.azure_openai_deployment_name: raise ValueError("model_deployment_name is required when creating Knowledge Base from index") + if not self.index_name: + raise ValueError("index_name is required when creating Knowledge Base from index") # Step 1: Create or get knowledge source from index knowledge_source_name = f"{self.index_name}-source" From 8ce152833864891f65ec1c851b214aed286ecca6 Mon Sep 17 00:00:00 2001 From: Farzad Date: Thu, 18 Jun 2026 08:32:00 -0500 Subject: [PATCH 4/4] Python: support stable + preview Azure AI Search (Foundry IQ) API versions Update agent-framework-azure-ai-search to work across the stable/GA azure-search-documents SDK (12.0.0, api-version 2026-04-01) and the preview SDK (12.1.0b1, api-version 2026-05-01-preview) for both semantic and agentic modes. - Bump the dependency to azure-search-documents>=12.0.0,<13 and the package to 1.0.0b260618. - Add an api_version parameter (threaded into SearchClient, SearchIndexClient, and KnowledgeBaseRetrievalClient) plus STABLE_API_VERSION/PREVIEW_API_VERSION constants, re-exported from agent_framework.azure. - Auto-detect preview-only agentic features (output mode, low/medium reasoning effort) via _preview_features_active(), which requires both the preview SDK and a preview api-version; defaults (extractive + minimal) work on both channels and preview-only options raise an actionable error otherwise. - Make knowledge-base imports SDK-version resilient and fix the 12.x surface (k -> k_nearest_neighbors, defensive additional_properties). - Update tests (pass on both SDKs), docs, samples, CHANGELOG, and uv.lock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/CHANGELOG.md | 4 + python/packages/azure-ai-search/AGENTS.md | 25 ++ python/packages/azure-ai-search/README.md | 23 ++ .../__init__.py | 9 +- .../_context_provider.py | 253 ++++++++++++------ .../packages/azure-ai-search/pyproject.toml | 6 +- .../tests/test_aisearch_context_provider.py | 240 +++++++++++++++-- .../core/agent_framework/azure/__init__.py | 2 + .../azure_ai_search/README.md | 24 +- .../azure_ai_search/search_context_agentic.py | 14 +- .../search_context_semantic.py | 4 + python/uv.lock | 28 +- 12 files changed, 491 insertions(+), 141 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index ca368cf134..6184c58459 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **agent-framework-azure-ai-search**: Support the stable/GA (`azure-search-documents` `12.0.0`, api-version `2026-04-01`) and preview (`12.1.0b1`, api-version `2026-05-01-preview`) Azure AI Search SDKs across semantic and agentic modes. Bump the dependency to `>=12.0.0,<13`, add an `api_version` parameter plus `STABLE_API_VERSION`/`PREVIEW_API_VERSION` constants, and auto-detect preview-only agentic features (output mode, low/medium reasoning effort) with actionable errors on the stable SDK. + ## [1.9.0] - 2026-06-18 ### Added diff --git a/python/packages/azure-ai-search/AGENTS.md b/python/packages/azure-ai-search/AGENTS.md index 114ee9d9ab..706081a81c 100644 --- a/python/packages/azure-ai-search/AGENTS.md +++ b/python/packages/azure-ai-search/AGENTS.md @@ -7,6 +7,31 @@ Integration with Azure AI Search for RAG (Retrieval-Augmented Generation). - **`AzureAISearchContextProvider`** - Context provider that retrieves relevant documents from Azure AI Search - **`AzureAISearchSettings`** - Pydantic settings for Azure AI Search configuration +## Constants + +- **`STABLE_API_VERSION`** (`"2026-04-01"`) - data-plane REST api-version of the stable/GA SDK +- **`PREVIEW_API_VERSION`** (`"2026-05-01-preview"`) - data-plane REST api-version of the preview SDK + +## API versions: stable vs preview + +The package depends on `azure-search-documents>=12.0.0,<13`, which spans both channels: + +| Channel | Install | SDK | Default REST `api-version` | +| --- | --- | --- | --- | +| **Stable / GA** | `pip install azure-search-documents` | `12.0.0` | `2026-04-01` | +| **Preview** | `pip install --pre azure-search-documents` | `12.1.0b1` | `2026-05-01-preview` | + +`AzureAISearchContextProvider(..., api_version=...)` forwards the api-version to the +`SearchClient`, `SearchIndexClient`, and `KnowledgeBaseRetrievalClient`. When `api_version` +is `None` (default), the installed SDK chooses its own default. + +Capability gating is auto-detected via `_preview_features_active()`, which requires **both** +the preview SDK (`_preview_agentic_features_available`) **and** a preview `api_version` (a +pinned stable api-version such as `2026-04-01` uses the GA wire and disables preview fields). +Agentic **output mode** (`answer_synthesis`) and **extended reasoning effort** (`low`/`medium`) +are preview-only; otherwise the provider omits them (extractive + minimal) and raises an +actionable `ValueError` if they are explicitly requested. Semantic mode is unaffected. + ## Usage ```python diff --git a/python/packages/azure-ai-search/README.md b/python/packages/azure-ai-search/README.md index fcd3161f94..af827699e2 100644 --- a/python/packages/azure-ai-search/README.md +++ b/python/packages/azure-ai-search/README.md @@ -13,6 +13,29 @@ The Azure AI Search integration provides context providers for RAG (Retrieval Au - **Semantic Mode**: Fast hybrid search (vector + keyword) with semantic ranking - **Agentic Mode**: Multi-hop reasoning using Knowledge Bases for complex queries +### API versions: stable vs preview + +The integration follows the `azure-search-documents` SDK's stable/preview channels: + +| Channel | Install | Default REST `api-version` | +| --- | --- | --- | +| **Stable** | `pip install azure-search-documents` (`>=12.0.0`) | `2026-04-01` | +| **Preview** | `pip install --pre azure-search-documents` (`12.1.0b1`) | `2026-05-01-preview` | + +By default the provider lets the installed SDK pick its `api-version`. Pass `api_version` +to pin it explicitly using the exported constants: + +```python +from agent_framework_azure_ai_search import PREVIEW_API_VERSION, STABLE_API_VERSION + +provider = AzureAISearchContextProvider(..., api_version=STABLE_API_VERSION) +``` + +Agentic **output modes** (`answer_synthesis`) and **extended reasoning effort** +(`low`/`medium`) are preview-only: they require the preview SDK and `PREVIEW_API_VERSION`. +On the stable SDK the provider uses extractive output with minimal reasoning effort, and +raises an actionable error if a preview-only option is requested. + ### Basic Usage Example See the [Azure AI Search context provider examples](../../samples/02-agents/context_providers/azure_ai_search/) which demonstrate: diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py index 9610be5774..a4cd20987d 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py @@ -2,7 +2,12 @@ import importlib.metadata -from ._context_provider import AzureAISearchContextProvider, AzureAISearchSettings +from ._context_provider import ( + PREVIEW_API_VERSION, + STABLE_API_VERSION, + AzureAISearchContextProvider, + AzureAISearchSettings, +) try: __version__ = importlib.metadata.version(__name__) @@ -10,6 +15,8 @@ __version__ = "0.0.0" # Fallback for development mode __all__ = [ + "PREVIEW_API_VERSION", + "STABLE_API_VERSION", "AzureAISearchContextProvider", "AzureAISearchSettings", "__version__", diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index 5a0b79f29d..cbb75f87de 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -35,11 +35,6 @@ AzureOpenAIVectorizerParameters, KnowledgeBase, KnowledgeBaseAzureOpenAIModel, - KnowledgeRetrievalLowReasoningEffort, - KnowledgeRetrievalMediumReasoningEffort, - KnowledgeRetrievalMinimalReasoningEffort, - KnowledgeRetrievalOutputMode, - KnowledgeRetrievalReasoningEffort, KnowledgeSourceReference, SearchIndexKnowledgeSource, SearchIndexKnowledgeSourceParameters, @@ -55,9 +50,9 @@ from agent_framework._agents import SupportsAgentRun from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseImageContent, KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseMessageTextContent, KnowledgeBaseReference, KnowledgeBaseRetrievalRequest, @@ -65,34 +60,24 @@ KnowledgeRetrievalIntent, KnowledgeRetrievalSemanticIntent, ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort, - ) from azure.search.documents.knowledgebases.models import ( KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort, ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalOutputMode as KBRetrievalOutputMode, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort, - ) if sys.version_info >= (3, 11): from typing import Self # pragma: no cover else: from typing_extensions import Self # pragma: no cover -# Runtime imports for agentic mode (optional dependency) +# Runtime imports for agentic mode. Core knowledge base retrieval works on both the +# stable/GA SDK (api-version 2026-04-01) and the preview SDK (api-version +# 2026-05-01-preview). try: from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseImageContent, KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseMessageTextContent, KnowledgeBaseReference, KnowledgeBaseRetrievalRequest, @@ -100,31 +85,54 @@ KnowledgeRetrievalIntent, KnowledgeRetrievalSemanticIntent, ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort, - ) from azure.search.documents.knowledgebases.models import ( KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort, ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalOutputMode as KBRetrievalOutputMode, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort, - ) _agentic_retrieval_available = True except ImportError: _agentic_retrieval_available = False +# Preview-only agentic capabilities (api-version 2026-05-01-preview). These symbols are +# absent from the stable/GA SDK (api-version 2026-04-01): there, the knowledge base +# definition and retrieval request do not expose an output mode or extended (low/medium) +# reasoning effort, and retrieval is intent-based only. They are accessed exclusively +# behind ``_preview_agentic_features_available`` checks. ``Any`` keeps the optional symbols +# usable under strict type checking against the stable SDK. +KBRetrievalLowReasoningEffort: Any = None +KBRetrievalMediumReasoningEffort: Any = None +KBRetrievalOutputMode: Any = None +try: + from azure.search.documents.knowledgebases.models import ( # type: ignore[attr-defined] + KnowledgeRetrievalLowReasoningEffort as _KBRetrievalLowReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( # type: ignore[attr-defined] + KnowledgeRetrievalMediumReasoningEffort as _KBRetrievalMediumReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( # type: ignore[attr-defined] + KnowledgeRetrievalOutputMode as _KBRetrievalOutputMode, + ) + + KBRetrievalLowReasoningEffort = _KBRetrievalLowReasoningEffort + KBRetrievalMediumReasoningEffort = _KBRetrievalMediumReasoningEffort + KBRetrievalOutputMode = _KBRetrievalOutputMode + _preview_agentic_features_available = True +except ImportError: + _preview_agentic_features_available = False + AzureCredentialTypes = TokenCredential | AsyncTokenCredential EmbeddingFunction = Callable[[str], Awaitable[list[float]]] | SupportsGetEmbeddings[str, list[float], Any] KnowledgeBaseOutputModeLiteral = Literal["extractive_data", "answer_synthesis"] RetrievalReasoningEffortLiteral = Literal["minimal", "medium", "low"] +#: Azure AI Search data-plane REST api-version used by the stable/GA SDK +#: (``azure-search-documents`` ``>=12.0.0``). Supports semantic and agentic retrieval. +STABLE_API_VERSION = "2026-04-01" +#: Azure AI Search data-plane REST api-version used by the preview SDK +#: (``pip install --pre azure-search-documents``). Adds agentic output modes and +#: extended (low/medium) reasoning effort on top of the stable surface. +PREVIEW_API_VERSION = "2026-05-01-preview" + logger = logging.getLogger("agent_framework.azure_ai_search") _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10 @@ -187,6 +195,7 @@ def __init__( knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + api_version: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -212,6 +221,9 @@ def __init__( knowledge_base_output_mode: Unused in semantic mode. retrieval_reasoning_effort: Unused in semantic mode. agentic_message_history_count: Unused in semantic mode. + api_version: Azure AI Search data-plane REST api-version. ``None`` uses the installed + SDK default (stable SDK -> 2026-04-01, preview SDK -> 2026-05-01-preview). Use + ``STABLE_API_VERSION`` or ``PREVIEW_API_VERSION`` to pin explicitly. env_file_path: Optional ``.env`` file checked before process environment variables. env_file_encoding: Encoding for the ``.env`` file. """ @@ -240,6 +252,7 @@ def __init__( knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + api_version: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -265,6 +278,9 @@ def __init__( knowledge_base_output_mode: Output mode for Knowledge Base retrieval. retrieval_reasoning_effort: Reasoning effort for query planning. agentic_message_history_count: Number of recent messages included in retrieval. + api_version: Azure AI Search data-plane REST api-version. ``None`` uses the installed + SDK default (stable SDK -> 2026-04-01, preview SDK -> 2026-05-01-preview). Use + ``STABLE_API_VERSION`` or ``PREVIEW_API_VERSION`` to pin explicitly. env_file_path: Optional ``.env`` file checked before process environment variables. env_file_encoding: Encoding for the ``.env`` file. """ @@ -293,6 +309,7 @@ def __init__( knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + api_version: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -318,6 +335,9 @@ def __init__( knowledge_base_output_mode: Output mode for Knowledge Base retrieval. retrieval_reasoning_effort: Reasoning effort for query planning. agentic_message_history_count: Number of recent messages included in retrieval. + api_version: Azure AI Search data-plane REST api-version. ``None`` uses the installed + SDK default (stable SDK -> 2026-04-01, preview SDK -> 2026-05-01-preview). Use + ``STABLE_API_VERSION`` or ``PREVIEW_API_VERSION`` to pin explicitly. env_file_path: Optional ``.env`` file checked before process environment variables. env_file_encoding: Encoding for the ``.env`` file. """ @@ -346,6 +366,7 @@ def __init__( knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + api_version: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -375,6 +396,9 @@ def __init__( knowledge_base_output_mode: Output mode for Knowledge Base retrieval. retrieval_reasoning_effort: Reasoning effort for query planning. agentic_message_history_count: Number of recent messages included in retrieval. + api_version: Azure AI Search data-plane REST api-version. ``None`` uses the installed + SDK default (stable SDK -> 2026-04-01, preview SDK -> 2026-05-01-preview). Use + ``STABLE_API_VERSION`` or ``PREVIEW_API_VERSION`` to pin explicitly. env_file_path: Optional ``.env`` file checked before process environment variables. env_file_encoding: Encoding for the ``.env`` file. """ @@ -402,6 +426,7 @@ def __init__( knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + api_version: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -430,6 +455,11 @@ def __init__( knowledge_base_output_mode: Output mode for Knowledge Base retrieval. retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning. agentic_message_history_count: Number of recent messages for agentic mode. + api_version: Azure AI Search data-plane REST api-version. ``None`` (default) uses the + installed SDK default (stable SDK -> 2026-04-01, preview SDK -> 2026-05-01-preview). + Pass ``STABLE_API_VERSION`` ("2026-04-01") or ``PREVIEW_API_VERSION`` + ("2026-05-01-preview") to pin explicitly. Agentic output modes and low/medium + reasoning effort require the preview SDK and ``PREVIEW_API_VERSION``. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. """ @@ -505,6 +535,7 @@ def __init__( self.knowledge_base_output_mode = knowledge_base_output_mode self.retrieval_reasoning_effort = retrieval_reasoning_effort self.agentic_message_history_count = agentic_message_history_count + self.api_version = api_version self._use_existing_knowledge_base = False if mode == "agentic": @@ -522,12 +553,31 @@ def __init__( if mode == "agentic": if not _agentic_retrieval_available: raise ImportError( - "Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support." + "Agentic retrieval requires azure-search-documents >= 12.0.0 with Knowledge Base support." ) if not self._use_existing_knowledge_base and not self.azure_openai_resource_url: raise ValueError( "azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index." ) + if not self._preview_features_active(): + # Preview-only agentic options require BOTH the preview SDK and a preview + # api-version. On the stable/GA wire (api-version 2026-04-01) the knowledge + # base definition and retrieval request do not accept output mode or extended + # reasoning effort, so reject them up front instead of failing server-side. + if knowledge_base_output_mode != "extractive_data": + raise ValueError( + f"knowledge_base_output_mode={knowledge_base_output_mode!r} requires the preview " + "azure-search-documents SDK and a preview api-version. Install it with " + "`pip install --pre azure-search-documents` and set " + f"api_version=PREVIEW_API_VERSION ({PREVIEW_API_VERSION!r}), or use 'extractive_data'." + ) + if retrieval_reasoning_effort != "minimal": + raise ValueError( + f"retrieval_reasoning_effort={retrieval_reasoning_effort!r} requires the preview " + "azure-search-documents SDK and a preview api-version. Install it with " + "`pip install --pre azure-search-documents` and set " + f"api_version=PREVIEW_API_VERSION ({PREVIEW_API_VERSION!r}), or use 'minimal'." + ) self._search_client: SearchClient | None = None if self.index_name: @@ -535,7 +585,7 @@ def __init__( endpoint=self.endpoint, index_name=self.index_name, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) self._index_client: SearchIndexClient | None = None @@ -544,11 +594,33 @@ def __init__( self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) self._knowledge_base_initialized = False + def _common_client_kwargs(self) -> dict[str, Any]: + """Build the keyword arguments shared by every Azure AI Search client. + + ``api_version`` is only forwarded when explicitly set so that, when ``None``, the + installed SDK selects its own default (stable -> 2026-04-01, preview -> + 2026-05-01-preview). + """ + kwargs: dict[str, Any] = {"user_agent": get_user_agent()} + if self.api_version is not None: + kwargs["api_version"] = self.api_version + return kwargs + + def _preview_features_active(self) -> bool: + """Whether preview-only agentic features (output mode, low/medium effort) are usable. + + They require BOTH the preview SDK (``_preview_agentic_features_available``) and a + preview ``api-version``. A pinned stable api-version (e.g. ``2026-04-01``) uses the GA + wire protocol, which rejects those fields even when the preview SDK is installed. + """ + api_version_supports_preview = self.api_version is None or self.api_version.endswith("-preview") + return _preview_agentic_features_available and api_version_supports_preview + async def __aenter__(self) -> Self: """Async context manager entry.""" return self @@ -640,7 +712,7 @@ async def _auto_discover_vector_field(self) -> None: self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) if not self.index_name: logger.warning("Cannot auto-discover vector field: index_name is not set.") @@ -695,14 +767,18 @@ async def _semantic_search(self, query: str) -> list[Message]: if self.vector_field_name: vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k if self._use_vectorizable_query: - vector_queries = [VectorizableTextQuery(text=query, k=vector_k, fields=self.vector_field_name)] + vector_queries = [ + VectorizableTextQuery(text=query, k_nearest_neighbors=vector_k, fields=self.vector_field_name) + ] elif self.embedding_function: if isinstance(self.embedding_function, SupportsGetEmbeddings): embeddings = await self.embedding_function.get_embeddings([query]) # type: ignore[reportUnknownVariableType] query_vector = embeddings[0].vector # type: ignore[reportUnknownVariableType] else: query_vector = await self.embedding_function(query) # type: ignore[reportUnknownVariableType] - vector_queries = [VectorizedQuery(vector=query_vector, k=vector_k, fields=self.vector_field_name)] # type: ignore[reportUnknownArgumentType] + vector_queries = [ + VectorizedQuery(vector=query_vector, k_nearest_neighbors=vector_k, fields=self.vector_field_name) # type: ignore[reportUnknownArgumentType] + ] search_params: dict[str, Any] = {"search_text": query, "top": self.top_k} if vector_queries: @@ -740,7 +816,7 @@ async def _ensure_knowledge_base(self) -> None: endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) self._knowledge_base_initialized = True return @@ -774,26 +850,29 @@ async def _ensure_knowledge_base(self) -> None: api_key=self.azure_openai_api_key, ) - output_mode = ( - KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA - if self.knowledge_base_output_mode == "extractive_data" - else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS - ) - reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = { - "minimal": KnowledgeRetrievalMinimalReasoningEffort(), - "medium": KnowledgeRetrievalMediumReasoningEffort(), - "low": KnowledgeRetrievalLowReasoningEffort(), + kb_kwargs: dict[str, Any] = { + "name": knowledge_base_name, + "description": f"Knowledge Base for multi-hop retrieval across {self.index_name}", + "knowledge_sources": [KnowledgeSourceReference(name=knowledge_source_name)], + "models": [KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)], } - reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort] - - knowledge_base = KnowledgeBase( - name=knowledge_base_name, - description=f"Knowledge Base for multi-hop retrieval across {self.index_name}", - knowledge_sources=[KnowledgeSourceReference(name=knowledge_source_name)], - models=[KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)], - output_mode=output_mode, - retrieval_reasoning_effort=reasoning_effort, - ) + if self._preview_features_active(): + # Output mode and reasoning effort on the knowledge base definition require the + # preview SDK and a preview api-version; the stable/GA wire omits them (validated + # as defaults in __init__). + kb_kwargs["output_mode"] = ( + KBRetrievalOutputMode.EXTRACTIVE_DATA + if self.knowledge_base_output_mode == "extractive_data" + else KBRetrievalOutputMode.ANSWER_SYNTHESIS + ) + kb_reasoning_effort_map = { + "minimal": KBRetrievalMinimalReasoningEffort(), + "medium": KBRetrievalMediumReasoningEffort(), + "low": KBRetrievalLowReasoningEffort(), + } + kb_kwargs["retrieval_reasoning_effort"] = kb_reasoning_effort_map[self.retrieval_reasoning_effort] + + knowledge_base = KnowledgeBase(**kb_kwargs) await self._index_client.create_or_update_knowledge_base(knowledge_base) self._knowledge_base_initialized = True @@ -802,43 +881,40 @@ async def _ensure_knowledge_base(self) -> None: endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=get_user_agent(), + **self._common_client_kwargs(), ) async def _agentic_search(self, messages: list[Message]) -> list[Message]: """Perform agentic retrieval with multi-hop reasoning.""" await self._ensure_knowledge_base() - reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = { - "minimal": KBRetrievalMinimalReasoningEffort(), - "medium": KBRetrievalMediumReasoningEffort(), - "low": KBRetrievalLowReasoningEffort(), - } - reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort] - - output_mode = ( - KBRetrievalOutputMode.EXTRACTIVE_DATA - if self.knowledge_base_output_mode == "extractive_data" - else KBRetrievalOutputMode.ANSWER_SYNTHESIS - ) + request_kwargs: dict[str, Any] = {"include_activity": True} + if self._preview_features_active(): + # Reasoning effort and output mode on the retrieval request require the preview + # SDK and a preview api-version; the stable/GA wire rejects them. + request_reasoning_effort_map = { + "minimal": KBRetrievalMinimalReasoningEffort(), + "medium": KBRetrievalMediumReasoningEffort(), + "low": KBRetrievalLowReasoningEffort(), + } + request_kwargs["retrieval_reasoning_effort"] = request_reasoning_effort_map[self.retrieval_reasoning_effort] + request_kwargs["output_mode"] = ( + KBRetrievalOutputMode.EXTRACTIVE_DATA + if self.knowledge_base_output_mode == "extractive_data" + else KBRetrievalOutputMode.ANSWER_SYNTHESIS + ) if self.retrieval_reasoning_effort == "minimal": query = "\n".join(msg.text for msg in messages if msg.text) intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)] - retrieval_request = KnowledgeBaseRetrievalRequest( - intents=intents, - retrieval_reasoning_effort=reasoning_effort, - output_mode=output_mode, - include_activity=True, - ) + request_kwargs["intents"] = intents else: - kb_messages = self._prepare_messages_for_kb_search(messages) - retrieval_request = KnowledgeBaseRetrievalRequest( - messages=kb_messages, - retrieval_reasoning_effort=reasoning_effort, - output_mode=output_mode, - include_activity=True, - ) + # Messages-based retrieval (multi-hop query planning) is preview-only; reaching + # this branch requires low/medium reasoning effort, which __init__ already + # rejects on the stable/GA SDK. + request_kwargs["messages"] = self._prepare_messages_for_kb_search(messages) + + retrieval_request = KnowledgeBaseRetrievalRequest(**request_kwargs) if not self._retrieval_client: raise RuntimeError("Retrieval client not initialized.") @@ -872,7 +948,7 @@ def _prepare_messages_for_kb_search(messages: list[Message]) -> list[KnowledgeBa ): kb_content.append( KnowledgeBaseMessageImageContent( - image=KnowledgeBaseMessageImageContentImage(url=content.uri), + image=KnowledgeBaseImageContent(url=content.uri), ) ) case _: @@ -924,8 +1000,9 @@ def _parse_references_to_annotations(references: list[KnowledgeBaseReference] | doc_key = getattr(ref, "doc_key", None) if doc_key: extra["doc_key"] = doc_key - if ref.additional_properties: - extra["sdk_additional_properties"] = ref.additional_properties + sdk_additional_properties = getattr(ref, "additional_properties", None) + if sdk_additional_properties: + extra["sdk_additional_properties"] = sdk_additional_properties sensitivity_info = getattr(ref, "search_sensitivity_label_info", None) if sensitivity_info: extra["sensitivity_label"] = { @@ -995,4 +1072,4 @@ def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) return text -__all__ = ["AzureAISearchContextProvider"] +__all__ = ["PREVIEW_API_VERSION", "STABLE_API_VERSION", "AzureAISearchContextProvider"] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index dbfe618f8b..4f63f27539 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260521" +version = "1.0.0b260618" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.6.0,<2", - "azure-search-documents>=11.7.0b2,<11.7.0b3", + # Stable/GA (12.0.0) targets Azure AI Search api-version 2026-04-01; the preview + # line (e.g. 12.1.0b1, installed with --pre) targets api-version 2026-05-01-preview. + "azure-search-documents>=12.0.0,<13", ] [tool.uv] diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 64c12e0724..8a5f4ddbe5 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -2,6 +2,8 @@ # pyright: reportPrivateUsage=false import os +from collections.abc import Iterator +from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -11,6 +13,7 @@ from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials import AzureKeyCredential +from agent_framework_azure_ai_search import _context_provider from agent_framework_azure_ai_search._context_provider import AzureAISearchContextProvider # -- Helpers ------------------------------------------------------------------- @@ -96,6 +99,44 @@ def _make_provider(**overrides) -> AzureAISearchContextProvider: return provider +# -- Preview-feature stubs ---------------------------------------------------- +# The stable/GA azure-search-documents SDK (api-version 2026-04-01) does not ship the +# preview-only agentic symbols (output mode, low/medium reasoning effort, image content, +# messages-based retrieval request). These stubs let the preview code paths be exercised +# deterministically regardless of which SDK is installed. + + +class _StubReasoningEffort: + def __init__(self, *args: object, **kwargs: object) -> None: ... + + +class _StubOutputMode: + EXTRACTIVE_DATA = "extractiveData" + ANSWER_SYNTHESIS = "answerSynthesis" + + +class _StubRetrievalRequest: + """Lenient stand-in for the preview KnowledgeBaseRetrievalRequest that accepts any kwargs.""" + + def __init__(self, **kwargs: object) -> None: + self.__dict__.update(kwargs) + + +@contextmanager +def force_preview_features() -> Iterator[None]: + """Force preview-only agentic features on (with lightweight stubs).""" + with patch.multiple( + _context_provider, + _preview_agentic_features_available=True, + KBRetrievalMinimalReasoningEffort=_StubReasoningEffort, + KBRetrievalMediumReasoningEffort=_StubReasoningEffort, + KBRetrievalLowReasoningEffort=_StubReasoningEffort, + KBRetrievalOutputMode=_StubOutputMode, + KnowledgeBaseRetrievalRequest=_StubRetrievalRequest, + ): + yield + + # -- Initialization: semantic mode --------------------------------------------- @@ -315,6 +356,148 @@ def test_agentic_explicit_index_ignores_env_kb_name(self) -> None: assert provider._use_existing_knowledge_base is False +# -- api_version + stable/preview feature gating ------------------------------ + + +class TestApiVersion: + """Tests for the api_version parameter and the version constants.""" + + def test_version_constants(self) -> None: + assert _context_provider.STABLE_API_VERSION == "2026-04-01" + assert _context_provider.PREVIEW_API_VERSION == "2026-05-01-preview" + + def test_api_version_defaults_to_none(self) -> None: + provider = _make_provider() + assert provider.api_version is None + + def test_api_version_none_not_forwarded(self) -> None: + provider = _make_provider() + kwargs = provider._common_client_kwargs() + assert "user_agent" in kwargs + assert "api_version" not in kwargs + + def test_api_version_explicit_forwarded(self) -> None: + provider = _make_provider(api_version=_context_provider.STABLE_API_VERSION) + assert provider.api_version == "2026-04-01" + assert provider._common_client_kwargs()["api_version"] == "2026-04-01" + + def test_api_version_passed_to_search_client(self) -> None: + with patch("agent_framework_azure_ai_search._context_provider.SearchClient") as mock_sc: + _make_provider(api_version=_context_provider.PREVIEW_API_VERSION) + _, kwargs = mock_sc.call_args + assert kwargs["api_version"] == "2026-05-01-preview" + + def test_api_version_passed_to_index_client_agentic(self) -> None: + with patch("agent_framework_azure_ai_search._context_provider.SearchIndexClient") as mock_ic: + AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + knowledge_base_name="kb", + api_key="key", + mode="agentic", + api_version=_context_provider.STABLE_API_VERSION, + ) + _, kwargs = mock_ic.call_args + assert kwargs["api_version"] == "2026-04-01" + + def test_preview_features_active_requires_preview_sdk_and_api(self) -> None: + with patch.object(_context_provider, "_preview_agentic_features_available", True): + assert _make_provider(api_version=None)._preview_features_active() is True + assert _make_provider(api_version=_context_provider.PREVIEW_API_VERSION)._preview_features_active() is True + # A pinned stable api-version disables preview features even on the preview SDK. + assert _make_provider(api_version=_context_provider.STABLE_API_VERSION)._preview_features_active() is False + with patch.object(_context_provider, "_preview_agentic_features_available", False): + assert _make_provider(api_version=_context_provider.PREVIEW_API_VERSION)._preview_features_active() is False + + +class TestPreviewFeatureGating: + """Auto-detect gating: preview-only agentic options require the preview SDK.""" + + def _agentic(self, **overrides: object) -> AzureAISearchContextProvider: + defaults: dict[str, object] = { + "endpoint": "https://test.search.windows.net", + "knowledge_base_name": "kb", + "api_key": "key", + "mode": "agentic", + } + defaults.update(overrides) + return AzureAISearchContextProvider(**defaults) # type: ignore[arg-type] + + def test_answer_synthesis_rejected_without_preview_sdk(self) -> None: + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + pytest.raises(ValueError, match="answer_synthesis"), + ): + self._agentic(knowledge_base_output_mode="answer_synthesis") + + def test_medium_effort_rejected_without_preview_sdk(self) -> None: + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + pytest.raises(ValueError, match="reasoning_effort"), + ): + self._agentic(retrieval_reasoning_effort="medium") + + def test_low_effort_rejected_without_preview_sdk(self) -> None: + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + pytest.raises(ValueError, match="reasoning_effort"), + ): + self._agentic(retrieval_reasoning_effort="low") + + def test_defaults_allowed_without_preview_sdk(self) -> None: + with patch.object(_context_provider, "_preview_agentic_features_available", False): + provider = self._agentic() + assert provider.knowledge_base_output_mode == "extractive_data" + assert provider.retrieval_reasoning_effort == "minimal" + + def test_preview_options_allowed_with_preview_sdk(self) -> None: + with patch.object(_context_provider, "_preview_agentic_features_available", True): + provider = self._agentic( + knowledge_base_output_mode="answer_synthesis", + retrieval_reasoning_effort="medium", + ) + assert provider.knowledge_base_output_mode == "answer_synthesis" + assert provider.retrieval_reasoning_effort == "medium" + + def test_stable_api_version_rejects_preview_option_even_with_preview_sdk(self) -> None: + # Preview SDK installed, but a pinned stable api-version uses the GA wire protocol, + # which would reject the preview-only field server-side -> fail fast with an error. + with ( + patch.object(_context_provider, "_preview_agentic_features_available", True), + pytest.raises(ValueError, match="reasoning_effort"), + ): + self._agentic(api_version=_context_provider.STABLE_API_VERSION, retrieval_reasoning_effort="medium") + + async def test_kb_creation_omits_preview_fields_on_stable_sdk(self) -> None: + provider = _make_provider() + provider._knowledge_base_initialized = False + provider._use_existing_knowledge_base = False + provider.knowledge_base_name = "test-kb" + provider.azure_openai_resource_url = "https://aoai.openai.azure.com" + provider.azure_openai_model = "gpt-4" + provider.index_name = "test-index" + + captured: dict[str, object] = {} + + async def _capture(kb: object) -> None: + captured["kb"] = kb + + mock_index_client = AsyncMock() + mock_index_client.get_knowledge_source.return_value = Mock() + mock_index_client.create_or_update_knowledge_base = AsyncMock(side_effect=_capture) + provider._index_client = mock_index_client + + with ( + patch.object(_context_provider, "_preview_agentic_features_available", False), + patch("agent_framework_azure_ai_search._context_provider.KnowledgeBaseRetrievalClient") as mock_cls, + ): + mock_cls.return_value = AsyncMock() + await provider._ensure_knowledge_base() + + kb = captured["kb"] + assert getattr(kb, "output_mode", None) is None + assert getattr(kb, "retrieval_reasoning_effort", None) is None + + # -- __aenter__ / __aexit__ --------------------------------------------------- @@ -1182,9 +1365,12 @@ async def test_non_minimal_reasoning_uses_messages(self) -> None: mock_retrieval.retrieve = AsyncMock(return_value=mock_result) provider._retrieval_client = mock_retrieval - with patch( - "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", - type(mock_content), + with ( + force_preview_features(), + patch( + "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", + type(mock_content), + ), ): results = await provider._agentic_search([ Message(role="user", contents=["question"]), @@ -1253,9 +1439,12 @@ async def test_answer_synthesis_output_mode(self) -> None: mock_retrieval.retrieve = AsyncMock(return_value=mock_result) provider._retrieval_client = mock_retrieval - with patch( - "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", - type(mock_content), + with ( + force_preview_features(), + patch( + "agent_framework_azure_ai_search._context_provider.KnowledgeBaseMessageTextContent", + type(mock_content), + ), ): results = await provider._agentic_search([Message(role="user", contents=["query"])]) @@ -1336,7 +1525,6 @@ def test_text_only_messages(self) -> None: assert result[0].content[0].text == "hello" def test_image_uri_content(self) -> None: - img = Content.from_uri(uri="https://example.com/photo.png", media_type="image/png") messages = [Message(role="user", contents=[img])] result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages) @@ -1347,7 +1535,6 @@ def test_image_uri_content(self) -> None: assert result[0].content[0].image.url == "https://example.com/photo.png" def test_mixed_text_and_image_content(self) -> None: - text = Content.from_text("describe this image") img = Content.from_uri(uri="https://example.com/img.jpg", media_type="image/jpeg") messages = [Message(role="user", contents=[text, img])] @@ -1376,7 +1563,6 @@ def test_fallback_to_msg_text_when_no_contents(self) -> None: assert result[0].content[0].text == "fallback text" def test_data_uri_image(self) -> None: - img = Content.from_data(data=b"\x89PNG", media_type="image/png") messages = [Message(role="user", contents=[img])] result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages) @@ -1459,18 +1645,19 @@ def test_raw_representation_stores_original_ref(self) -> None: assert result[0]["raw_representation"] is ref def test_remote_sharepoint_captures_sensitivity_label(self) -> None: - from azure.search.documents.knowledgebases.models import ( - KnowledgeBaseRemoteSharePointReference, - SharePointSensitivityLabelInfo, + # KnowledgeBaseRemoteSharePointReference is preview-only; a SimpleNamespace fake keeps this + # test runnable on the stable/GA SDK while exercising the same parsing branches. + ref = SimpleNamespace( + id="ref-6", + activity_source=0, + reranker_score=None, + source_data=None, + web_url="https://sp.example.com/doc", + search_sensitivity_label_info=SimpleNamespace( + display_name="Confidential", sensitivity_label_id="lbl-1", is_encrypted=True + ), ) - - label = SharePointSensitivityLabelInfo( - display_name="Confidential", sensitivity_label_id="lbl-1", is_encrypted=True - ) - ref = KnowledgeBaseRemoteSharePointReference( - id="ref-6", activity_source=0, web_url="https://sp.example.com/doc", search_sensitivity_label_info=label - ) - result = AzureAISearchContextProvider._parse_references_to_annotations([ref]) + result = AzureAISearchContextProvider._parse_references_to_annotations([ref]) # type: ignore[list-item] assert result[0]["url"] == "https://sp.example.com/doc" sl = result[0]["additional_properties"]["sensitivity_label"] assert sl["display_name"] == "Confidential" @@ -1537,18 +1724,18 @@ def test_image_content(self) -> None: from azure.search.documents.knowledgebases.models import ( KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseRetrievalResponse, ) + # The inner image object differs across SDKs (preview KnowledgeBaseMessageImageContentImage + # vs stable KnowledgeBaseImageContent); both expose ``url``. A SimpleNamespace keeps the + # parsing assertion SDK-agnostic. response = KnowledgeBaseRetrievalResponse( response=[ KnowledgeBaseMessage( role="assistant", content=[ - KnowledgeBaseMessageImageContent( - image=KnowledgeBaseMessageImageContentImage(url="https://img.example.com/a.png") - ) + KnowledgeBaseMessageImageContent(image=SimpleNamespace(url="https://img.example.com/a.png")) ], ), ], @@ -1563,7 +1750,6 @@ def test_mixed_text_and_image_content(self) -> None: from azure.search.documents.knowledgebases.models import ( KnowledgeBaseMessage, KnowledgeBaseMessageImageContent, - KnowledgeBaseMessageImageContentImage, KnowledgeBaseMessageTextContent, KnowledgeBaseRetrievalResponse, ) @@ -1574,9 +1760,7 @@ def test_mixed_text_and_image_content(self) -> None: role="assistant", content=[ KnowledgeBaseMessageTextContent(text="description"), - KnowledgeBaseMessageImageContent( - image=KnowledgeBaseMessageImageContentImage(url="https://img.example.com/b.png") - ), + KnowledgeBaseMessageImageContent(image=SimpleNamespace(url="https://img.example.com/b.png")), ], ), ], diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 7cff0150f1..14229f0c4d 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -19,6 +19,8 @@ "DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentWorker": ("agent_framework_durabletask", "agent-framework-durabletask"), + "PREVIEW_API_VERSION": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), + "STABLE_API_VERSION": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), } diff --git a/python/samples/02-agents/context_providers/azure_ai_search/README.md b/python/samples/02-agents/context_providers/azure_ai_search/README.md index 2e32819003..1ac08ca3dc 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/README.md +++ b/python/samples/02-agents/context_providers/azure_ai_search/README.md @@ -14,7 +14,7 @@ This folder contains examples demonstrating how to use the Azure AI Search conte ## Installation ```bash -pip install agent-framework-foundry-search agent-framework-foundry +pip install agent-framework-azure-ai-search agent-framework-foundry ``` ## Prerequisites @@ -42,6 +42,27 @@ Both examples support two authentication methods: Run `az login` if using Entra ID authentication. +### API versions (stable vs preview) + +The provider follows the `azure-search-documents` SDK channels: + +- **Stable / GA** — `pip install azure-search-documents` (`12.0.0`), default api-version `2026-04-01`. +- **Preview** — `pip install --pre azure-search-documents` (`12.1.0b1`), default api-version `2026-05-01-preview`. + +Pass `api_version` to pin it explicitly (otherwise the installed SDK's default is used): + +```python +from agent_framework.azure import AzureAISearchContextProvider, STABLE_API_VERSION + +provider = AzureAISearchContextProvider(..., api_version=STABLE_API_VERSION) +``` + +Agentic `knowledge_base_output_mode="answer_synthesis"` and +`retrieval_reasoning_effort` of `"low"`/`"medium"` are **preview-only** (require the +preview SDK + `PREVIEW_API_VERSION`). On the stable SDK the provider uses extractive +output with minimal reasoning effort and raises an actionable error if a preview-only +option is requested. + ## Configuration ### Environment Variables @@ -211,6 +232,7 @@ async with Agent( - `credential`: Azure credential for Entra ID auth (e.g., `DefaultAzureCredential()`) - `mode`: Search mode - `"semantic"` (default) or `"agentic"` - `top_k`: Number of documents to retrieve (default: 3 for semantic, 5 for agentic) +- `api_version`: Data-plane REST api-version. `None` (default) uses the installed SDK's default; pass `STABLE_API_VERSION` (`"2026-04-01"`) or `PREVIEW_API_VERSION` (`"2026-05-01-preview"`) to pin it. ### Semantic Mode Parameters diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py index 2d57a2906b..fe4ec99932 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py @@ -83,8 +83,8 @@ async def main() -> None: mode="agentic", knowledge_base_name=knowledge_base_name, # Optional: Configure retrieval behavior - knowledge_base_output_mode="extractive_data", # or "answer_synthesis" - retrieval_reasoning_effort="minimal", # or "medium", "low" + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" (preview SDK only) + retrieval_reasoning_effort="minimal", # or "medium", "low" (preview SDK only) ) else: # Auto-create Knowledge Base from index @@ -100,10 +100,14 @@ async def main() -> None: credential=AzureCliCredential() if not search_key else None, mode="agentic", azure_openai_resource_url=azure_openai_resource_url, - model_deployment_name=model_deployment, + model=model_deployment, + # Optional: pin the data-plane api-version. Defaults to the installed SDK's + # default (stable -> 2026-04-01, preview -> 2026-05-01-preview). Output modes + # and low/medium reasoning effort below require the preview SDK + api_version. + # api_version=PREVIEW_API_VERSION, # Optional: Configure retrieval behavior - knowledge_base_output_mode="extractive_data", # or "answer_synthesis" - retrieval_reasoning_effort="minimal", # or "medium", "low" + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" (preview SDK only) + retrieval_reasoning_effort="minimal", # or "medium", "low" (preview SDK only) top_k=3, ) diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index 5f2a57f511..c107488696 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -76,6 +76,10 @@ async def main() -> None: credential=credential if not search_key else None, mode="semantic", # Default mode top_k=3, # Retrieve top 3 most relevant documents + # Optional: pin the data-plane api-version (defaults to the installed SDK's default: + # stable -> 2026-04-01, preview -> 2026-05-01-preview). Import STABLE_API_VERSION / + # PREVIEW_API_VERSION from agent_framework.azure to set it explicitly. + # api_version=STABLE_API_VERSION, embedding_function=embedding_client, # Provide embedding function for hybrid search vector_field_name="DescriptionVector" if embedding_client diff --git a/python/uv.lock b/python/uv.lock index 6adc8f7d68..006e857eb6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'darwin'", @@ -228,7 +228,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260521" +version = "1.0.0b260618" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -238,7 +238,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-search-documents", specifier = ">=11.7.0b2,<11.7.0b3" }, + { name = "azure-search-documents", specifier = ">=12.0.0,<13" }, ] [[package]] @@ -1267,15 +1267,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/cf/90f27a2b48c9b748f84194b07e565f900e7f0ce0500da9b9f067dca599d3/azure_ai_projects-2.2.0-py3-none-any.whl", hash = "sha256:8f89bdaca4df1bd479d3bd2bd0f19a0905d60be6d17b84a69e8fabd82eac5906", size = 344307, upload-time = "2026-05-30T00:21:00.672Z" }, ] -[[package]] -name = "azure-common" -version = "1.1.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3e/71/f6f71a276e2e69264a97ad39ef850dca0a04fce67b12570730cb38d0ccac/azure-common-1.1.28.zip", hash = "sha256:4ac0cd3214e36b6a1b6a442686722a5d8cc449603aa833f3f0f40bda836704a3", size = 20914, upload-time = "2022-02-03T19:39:44.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/55/7f118b9c1b23ec15ca05d15a578d8207aa1706bc6f7c87218efffbbf875d/azure_common-1.1.28-py2.py3-none-any.whl", hash = "sha256:5c12d3dcf4ec20599ca6b0d3e09e86e146353d443e7fcc050c9a19c1f9df20ad", size = 14462, upload-time = "2022-02-03T19:39:42.417Z" }, -] - [[package]] name = "azure-core" version = "1.39.0" @@ -1404,17 +1395,16 @@ wheels = [ [[package]] name = "azure-search-documents" -version = "11.7.0b2" +version = "12.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/ba/bde0f03e0a742ba3bbcc929f91ed2f3b1420c2bb84c9a7f878f3b87ebfce/azure_search_documents-11.7.0b2.tar.gz", hash = "sha256:b6e039f8038ff2210d2057e704e867c6e29bb46bfcd400da4383e45e4b8bb189", size = 423956, upload-time = "2025-11-14T20:09:32.876Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/dc/bb4db263381aa5b29414e280a8535a343d877a3831a501ef39332174c85c/azure_search_documents-12.0.0.tar.gz", hash = "sha256:8e6d73ec0ed1623083435b757e34324db65d72d4e09cca061a59fc7e90c8ddbc", size = 386222, upload-time = "2026-05-01T20:28:22.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/26/ed4498374f9088818278ac225f2bea688b4ec979d81bf83a5355c8c366af/azure_search_documents-11.7.0b2-py3-none-any.whl", hash = "sha256:f82117b321344a84474269ed26df194c24cca619adc024d981b1b86aee3c6f05", size = 432037, upload-time = "2025-11-14T20:09:34.347Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b1/4869a064dbb79fb4ecac684de51a8f8f7a93a315f3f9cc4bf8a65cc413cd/azure_search_documents-12.0.0-py3-none-any.whl", hash = "sha256:d88114e4179cd753845711042380a4571e7faa8619addf5e017928ebe37fc0d1", size = 352117, upload-time = "2026-05-01T20:28:23.989Z" }, ] [[package]] @@ -2720,6 +2710,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2727,6 +2718,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2735,6 +2727,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2743,6 +2736,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2751,6 +2745,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2759,6 +2754,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },