From b7ac688b2b5c6ef39aa73e4d9760a31b6aa3488f Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 9 Mar 2026 15:13:58 -0700 Subject: [PATCH] Replace SearXNG integration tests with unit tests for request/response transformation The SearXNG search tests were failing in CI because they depend on a live SearXNG instance that returns results. Since this provider is used by a very small subset of customers, replace the flaky integration tests with deterministic unit tests that validate request payloads, URL construction, response parsing, and header configuration without requiring external infra. Co-Authored-By: Claude Opus 4.6 --- tests/search_tests/test_searxng_search.py | 414 +++++++++++++++++----- 1 file changed, 316 insertions(+), 98 deletions(-) diff --git a/tests/search_tests/test_searxng_search.py b/tests/search_tests/test_searxng_search.py index 50d5876973..8a8ac1405d 100644 --- a/tests/search_tests/test_searxng_search.py +++ b/tests/search_tests/test_searxng_search.py @@ -1,109 +1,327 @@ -import pytest -import litellm +""" +Unit tests for SearXNG Search request/response transformation. + +These tests validate the request payload and response parsing without +requiring a live SearXNG instance. +""" + +import json import os -from typing import List, Union +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlparse -from tests.search_tests.base_search_unit_tests import BaseSearchTest +import httpx +import pytest + +from litellm.llms.searxng.search.transformation import SearXNGSearchConfig -class TestSearXNGSearch(BaseSearchTest): +class TestSearXNGSearchRequestTransformation: """ - Tests for SearXNG Search functionality. + Tests that SearXNG search requests are transformed into the expected payload. """ - - def get_search_provider(self) -> str: - """ - Return search_provider for SearXNG Search. - """ - return "searxng" - - @pytest.mark.asyncio - async def test_basic_search(self): - """ - Test basic search functionality with a simple query. - Override to handle free (0.0 cost) provider. - """ - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - litellm._turn_on_debug() - search_provider = self.get_search_provider() - print("Search Provider=", search_provider) - try: - response = await litellm.asearch( - query="latest developments in AI", - search_provider=search_provider, - ) - print("Search response=", response.model_dump_json(indent=4)) + def setup_method(self): + self.config = SearXNGSearchConfig() - print(f"\n{'='*80}") - print(f"Response type: {type(response)}") - print(f"Response object: {response.object if hasattr(response, 'object') else 'N/A'}") - - # Check if response has expected Search format - assert hasattr(response, "results"), "Response should have 'results' attribute" - assert hasattr(response, "object"), "Response should have 'object' attribute" - assert response.object == "search", f"Expected object='search', got '{response.object}'" - - # Validate results structure - assert isinstance(response.results, list), "results should be a list" - assert len(response.results) > 0, "Should have at least one result" - - # Check first result structure - first_result = response.results[0] - assert hasattr(first_result, "title"), "Result should have 'title' attribute" - assert hasattr(first_result, "url"), "Result should have 'url' attribute" - assert hasattr(first_result, "snippet"), "Result should have 'snippet' attribute" - - print(f"Total results: {len(response.results)}") - print(f"First result title: {first_result.title}") - print(f"First result URL: {first_result.url}") - print(f"First result snippet: {first_result.snippet[:100]}...") - print(f"{'='*80}\n") - - assert len(first_result.title) > 0, "Title should not be empty" - assert len(first_result.url) > 0, "URL should not be empty" - assert len(first_result.snippet) > 0, "Snippet should not be empty" - - # Validate cost tracking in _hidden_params - # For SearXNG (free provider), cost can be None or 0.0 - assert hasattr(response, "_hidden_params"), "Response should have '_hidden_params' attribute" - hidden_params = response._hidden_params - assert "response_cost" in hidden_params, "_hidden_params should contain 'response_cost'" - - response_cost = hidden_params["response_cost"] - # SearXNG is free, so cost can be None or 0.0 - if response_cost is not None: - assert isinstance(response_cost, (int, float)), "response_cost should be a number" - assert response_cost >= 0, "response_cost should be non-negative" - print(f"Cost tracking: ${response_cost:.6f}") - else: - print(f"Cost tracking: Free (None)") - - except Exception as e: - pytest.fail(f"Search call failed: {str(e)}") - - def test_search_with_optional_params(self): - """ - Test search with optional parameters. - Override for SearXNG since it doesn't natively limit results. - """ - litellm.set_verbose = True - search_provider = self.get_search_provider() - - response = litellm.search( - query="machine learning", - search_provider=search_provider, - max_results=5, + def test_basic_query_request(self): + """Test that a basic query produces the expected SearXNG request params.""" + result = self.config.transform_search_request( + query="artificial intelligence recent news", + optional_params={}, ) - # Validate response - assert hasattr(response, "results"), "Response should have 'results' attribute" - assert isinstance(response.results, list), "results should be a list" - assert len(response.results) > 0, "Should have at least one result" - # Note: SearXNG doesn't natively limit results, so we don't check <= 5 - - print(f"\nSearch with optional params validated:") - print(f" - Requested max_results: 5") - print(f" - Received results: {len(response.results)}") + assert "_searxng_params" in result + params = result["_searxng_params"] + assert params["q"] == "artificial intelligence recent news" + assert params["format"] == "json" + def test_list_query_joined(self): + """Test that a list query is joined into a single string.""" + result = self.config.transform_search_request( + query=["artificial intelligence", "recent news"], + optional_params={}, + ) + + params = result["_searxng_params"] + assert params["q"] == "artificial intelligence recent news" + assert params["format"] == "json" + + def test_country_to_language_mapping(self): + """Test that country codes are mapped to SearXNG language params.""" + test_cases = { + "us": "en", + "uk": "en", + "de": "de", + "fr": "fr", + "es": "es", + "jp": "ja", + "br": "br", # unmapped country passed through as-is + } + for country, expected_language in test_cases.items(): + result = self.config.transform_search_request( + query="test", + optional_params={"country": country}, + ) + params = result["_searxng_params"] + assert params["language"] == expected_language, ( + f"country={country} should map to language={expected_language}" + ) + + def test_max_results_ignored(self): + """Test that max_results is accepted but doesn't add extra params.""" + result = self.config.transform_search_request( + query="test", + optional_params={"max_results": 5}, + ) + + params = result["_searxng_params"] + assert params["q"] == "test" + assert params["format"] == "json" + # max_results should not appear in the SearXNG params + assert "max_results" not in params + + def test_searxng_specific_params_passthrough(self): + """Test that SearXNG-specific params are passed through as-is.""" + result = self.config.transform_search_request( + query="test", + optional_params={"categories": "general,news", "engines": "google,bing", "time_range": "month"}, + ) + + params = result["_searxng_params"] + assert params["q"] == "test" + assert params["format"] == "json" + assert params["categories"] == "general,news" + assert params["engines"] == "google,bing" + assert params["time_range"] == "month" + + +class TestSearXNGSearchURLConstruction: + """ + Tests that the complete URL is built correctly from api_base and request params. + """ + + def setup_method(self): + self.config = SearXNGSearchConfig() + + def test_url_with_search_suffix(self): + """Test URL construction appends /search.""" + data = {"_searxng_params": {"q": "test query", "format": "json"}} + url = self.config.get_complete_url( + api_base="https://searxng.example.com", + optional_params={}, + data=data, + ) + + parsed = urlparse(url) + assert parsed.scheme == "https" + assert parsed.netloc == "searxng.example.com" + assert parsed.path == "/search" + query_params = parse_qs(parsed.query) + assert query_params["q"] == ["test query"] + assert query_params["format"] == ["json"] + + def test_url_already_has_search_suffix(self): + """Test URL construction doesn't double-append /search.""" + data = {"_searxng_params": {"q": "test", "format": "json"}} + url = self.config.get_complete_url( + api_base="https://searxng.example.com/search", + optional_params={}, + data=data, + ) + + parsed = urlparse(url) + assert parsed.path == "/search" + assert "/search/search" not in url + + def test_url_with_trailing_slash(self): + """Test URL construction with trailing slash on api_base.""" + data = {"_searxng_params": {"q": "test", "format": "json"}} + url = self.config.get_complete_url( + api_base="https://searxng.example.com/", + optional_params={}, + data=data, + ) + + parsed = urlparse(url) + assert parsed.path == "/search" + + def test_url_from_env_variable(self): + """Test URL construction falls back to SEARXNG_API_BASE env var.""" + data = {"_searxng_params": {"q": "test", "format": "json"}} + with patch( + "litellm.llms.searxng.search.transformation.get_secret_str", + return_value="https://env-searxng.example.com", + ): + url = self.config.get_complete_url( + api_base=None, + optional_params={}, + data=data, + ) + + assert url.startswith("https://env-searxng.example.com/search?") + + def test_url_missing_api_base_raises(self): + """Test that missing api_base and env var raises ValueError.""" + with patch( + "litellm.llms.searxng.search.transformation.get_secret_str", + return_value=None, + ): + with pytest.raises(ValueError, match="SEARXNG_API_BASE is not set"): + self.config.get_complete_url( + api_base=None, + optional_params={}, + data={"_searxng_params": {"q": "test"}}, + ) + + def test_url_without_data_returns_base(self): + """Test URL construction without data returns just the api_base/search.""" + url = self.config.get_complete_url( + api_base="https://searxng.example.com", + optional_params={}, + data=None, + ) + + assert url == "https://searxng.example.com/search" + + +class TestSearXNGSearchResponseTransformation: + """ + Tests that SearXNG API responses are correctly transformed to SearchResponse. + """ + + def setup_method(self): + self.config = SearXNGSearchConfig() + self.logging_obj = MagicMock() + + def _make_mock_response(self, json_data: dict) -> httpx.Response: + response = httpx.Response( + status_code=200, + json=json_data, + request=httpx.Request("GET", "https://searxng.example.com/search"), + ) + return response + + def test_response_with_results(self): + """Test transforming a typical SearXNG response with results.""" + raw = self._make_mock_response({ + "results": [ + { + "title": "AI News Article", + "url": "https://example.com/ai-news", + "content": "Latest developments in artificial intelligence.", + "publishedDate": "2025-01-15", + }, + { + "title": "ML Research Paper", + "url": "https://example.com/ml-paper", + "content": "New machine learning research findings.", + "pubdate": "2025-01-10", + }, + ] + }) + + response = self.config.transform_search_response( + raw_response=raw, logging_obj=self.logging_obj + ) + + assert response.object == "search" + assert len(response.results) == 2 + + first = response.results[0] + assert first.title == "AI News Article" + assert first.url == "https://example.com/ai-news" + assert first.snippet == "Latest developments in artificial intelligence." + assert first.date == "2025-01-15" + assert first.last_updated is None + + second = response.results[1] + assert second.title == "ML Research Paper" + assert second.date == "2025-01-10" # from pubdate field + + def test_response_empty_results(self): + """Test transforming a response with no results.""" + raw = self._make_mock_response({"results": []}) + + response = self.config.transform_search_response( + raw_response=raw, logging_obj=self.logging_obj + ) + + assert response.object == "search" + assert response.results == [] + + def test_response_missing_results_key(self): + """Test transforming a response that has no 'results' key.""" + raw = self._make_mock_response({"query": "test"}) + + response = self.config.transform_search_response( + raw_response=raw, logging_obj=self.logging_obj + ) + + assert response.object == "search" + assert response.results == [] + + def test_response_missing_optional_fields(self): + """Test transforming results with missing optional fields.""" + raw = self._make_mock_response({ + "results": [ + { + "title": "Minimal Result", + "url": "https://example.com", + } + ] + }) + + response = self.config.transform_search_response( + raw_response=raw, logging_obj=self.logging_obj + ) + + result = response.results[0] + assert result.title == "Minimal Result" + assert result.url == "https://example.com" + assert result.snippet == "" # defaults to empty string + assert result.date is None + assert result.last_updated is None + + +class TestSearXNGSearchHeaders: + """ + Tests for header/environment validation. + """ + + def setup_method(self): + self.config = SearXNGSearchConfig() + + def test_headers_without_api_key(self): + """Test that headers are set correctly without an API key.""" + with patch( + "litellm.llms.searxng.search.transformation.get_secret_str", + return_value=None, + ): + headers = self.config.validate_environment(headers={}) + + assert headers["Content-Type"] == "application/json" + assert "Authorization" not in headers + + def test_headers_with_api_key(self): + """Test that headers include Authorization when API key is provided.""" + headers = self.config.validate_environment( + headers={}, api_key="test-key-123" + ) + + assert headers["Content-Type"] == "application/json" + assert headers["Authorization"] == "Bearer test-key-123" + + def test_headers_with_env_api_key(self): + """Test that headers use SEARXNG_API_KEY from env.""" + with patch( + "litellm.llms.searxng.search.transformation.get_secret_str", + return_value="env-key-456", + ): + headers = self.config.validate_environment(headers={}) + + assert headers["Authorization"] == "Bearer env-key-456" + + def test_http_method_is_get(self): + """Test that the HTTP method is GET.""" + assert self.config.get_http_method() == "GET"