mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 04:22:19 +00:00
Merge pull request #23192 from BerriAI/litellm_/pedantic-easley
[Test] Replace SearXNG integration tests with unit tests
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user