Merge pull request #23192 from BerriAI/litellm_/pedantic-easley

[Test] Replace SearXNG integration tests with unit tests
This commit is contained in:
yuneng-jiang
2026-03-09 15:20:47 -07:00
committed by GitHub
+316 -99
View File
@@ -1,110 +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)}")
@pytest.mark.flaky(retries=3, delay=5)
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"