From 60f3a3b0adb8dfc81a6c8ac7be90d47804b3fd35 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 4 Nov 2025 17:56:07 -0800 Subject: [PATCH] [Feat] add serxng search API provider (#16259) * TestFirecrawlSearch * add SearchProviders * add to get_provider_search_config * add FirecrawlSearchConfig * add FirecrawlSearchRequest * add firecrawl API docs * add pricing firecrawl/search * add new search APIs * add SearXNGSearchConfig * add searxng/search * add serxng params * TestSearXNGSearch * docs serxng * docs fix * docs fix * docs serxng --- docs/my-website/docs/search/index.md | 5 +- docs/my-website/docs/search/searxng.md | 318 ++++++++++++++++++ docs/my-website/sidebars.js | 2 + litellm/llms/searxng/__init__.py | 7 + litellm/llms/searxng/search/__init__.py | 7 + litellm/llms/searxng/search/transformation.py | 223 ++++++++++++ ...odel_prices_and_context_window_backup.json | 8 + litellm/types/utils.py | 1 + litellm/utils.py | 2 + model_prices_and_context_window.json | 8 + provider_endpoints_support.json | 17 + tests/search_tests/test_searxng_search.py | 109 ++++++ 12 files changed, 705 insertions(+), 2 deletions(-) create mode 100644 docs/my-website/docs/search/searxng.md create mode 100644 litellm/llms/searxng/__init__.py create mode 100644 litellm/llms/searxng/search/__init__.py create mode 100644 litellm/llms/searxng/search/transformation.py create mode 100644 tests/search_tests/test_searxng_search.py diff --git a/docs/my-website/docs/search/index.md b/docs/my-website/docs/search/index.md index 62df4aa9c9..1ec3cd5d6b 100644 --- a/docs/my-website/docs/search/index.md +++ b/docs/my-website/docs/search/index.md @@ -2,7 +2,7 @@ | Feature | Supported | |---------|-----------| -| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `google_pse`, `dataforseo`, `firecrawl` | +| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `google_pse`, `dataforseo`, `firecrawl`, `searxng` | | Cost Tracking | ✅ | | Logging | ✅ | | Load Balancing | ❌ | @@ -205,7 +205,7 @@ See the [official Perplexity Search documentation](https://docs.perplexity.ai/ap | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `query` | string or array | Yes | Search query. Can be a single string or array of strings | -| `search_provider` | string | Yes (SDK) | The search provider to use: `"perplexity"`, `"tavily"`, `"parallel_ai"`, `"exa_ai"`, `"google_pse"`, `"dataforseo"`, or `"firecrawl"` | +| `search_provider` | string | Yes (SDK) | The search provider to use: `"perplexity"`, `"tavily"`, `"parallel_ai"`, `"exa_ai"`, `"google_pse"`, `"dataforseo"`, `"firecrawl"`, or `"searxng"` | | `search_tool_name` | string | Yes (Proxy) | Name of the search tool configured in `config.yaml` | | `max_results` | integer | No | Maximum number of results to return (1-20). Default: 10 | | `search_domain_filter` | array | No | List of domains to filter results (max 20 domains) | @@ -268,6 +268,7 @@ The response follows Perplexity's search format with the following structure: | Google PSE | `GOOGLE_PSE_API_KEY`, `GOOGLE_PSE_ENGINE_ID` | `google_pse` | | DataForSEO | `DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD` | `dataforseo` | | Firecrawl | `FIRECRAWL_API_KEY` | `firecrawl` | +| SearXNG | `SEARXNG_API_BASE` (required) | `searxng` | See the individual provider documentation for detailed setup instructions and provider-specific parameters. diff --git a/docs/my-website/docs/search/searxng.md b/docs/my-website/docs/search/searxng.md new file mode 100644 index 0000000000..610be4a83a --- /dev/null +++ b/docs/my-website/docs/search/searxng.md @@ -0,0 +1,318 @@ +# SearXNG Search + +**Open Source:** [https://github.com/searxng/searxng](https://github.com/searxng/searxng) + +**Public Instances:** [https://searx.space/](https://searx.space/) + +## Overview + +SearXNG is a free, open-source metasearch engine that aggregates results from multiple search engines while protecting user privacy. It can be self-hosted or used via public instances. + +**Note:** SearXNG returns a fixed number of results per page (~20 by default) and does not support limiting results via the API. The `max_results` parameter is not directly supported by SearXNG. + +## LiteLLM Python SDK + +```python showLineNumbers title="SearXNG Search" +import os +from litellm import search + +# Set your SearXNG instance URL (REQUIRED) +os.environ["SEARXNG_API_BASE"] = "https://serxng-deployment-production.up.railway.app" + +response = search( + query="latest AI developments", + search_provider="searxng", + max_results=10 +) +``` + +## LiteLLM AI Gateway + +### 1. Setup config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: searxng-search + litellm_params: + search_provider: searxng + api_base: https://serxng-deployment-production.up.railway.app +``` + +### 2. Start the proxy + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### 3. Test the search endpoint + +```bash showLineNumbers title="Test Request" +curl http://0.0.0.0:4000/v1/search/searxng-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments", + "max_results": 10 + }' +``` + +## Provider-specific Parameters + +```python showLineNumbers title="SearXNG Search with Provider-specific Parameters" +import os +from litellm import search + +# REQUIRED: Set your SearXNG instance URL +os.environ["SEARXNG_API_BASE"] = "https://serxng-deployment-production.up.railway.app" + +response = search( + query="machine learning research", + search_provider="searxng", + max_results=10, + # SearXNG-specific parameters + categories="general,science", # Comma-separated categories + engines="google,duckduckgo,bing", # Comma-separated engines + language="en", # Language code + pageno=1, # Page number + time_range="month" # Time filter: day, month, year +) +``` + +## Features + +SearXNG provides powerful metasearch capabilities: + +### Multiple Search Engines +Aggregate results from multiple search engines simultaneously: +- Google, DuckDuckGo, Bing, Brave +- Wikipedia, Startpage +- And many more + +### Categories +Search within specific categories: +- `general` - General web search +- `science` - Scientific articles and papers +- `images` - Image search +- `news` - News articles +- `videos` - Video content +- `music` - Music and audio +- `files` - File search +- `it` - IT and technology +- `map` - Maps and location + +### Time-Based Filtering +Filter results by time range: +- `day` - Past day +- `month` - Past month +- `year` - Past year + +### Privacy-Focused +- No user tracking +- No cookies required +- No profiling +- No ads + +### Language Support +Support for 60+ languages with the `language` parameter. + +## Self-Hosting + +SearXNG can be self-hosted for complete control. + +### Quick Deploy + +Use our pre-configured deployment repository for easy setup: + +**[Fork and Deploy: github.com/BerriAI/serxng-deployment](https://github.com/BerriAI/serxng-deployment)** + +This repository includes: +- Docker and Docker Compose setup +- JSON API format pre-configured +- Ready to deploy + +### Manual Installation + +See the [official SearXNG installation instructions](https://docs.searxng.org/admin/installation.html) for detailed setup. + +**Important:** When you install SearXNG, the only active output format by default is the HTML format. You need to activate the JSON format to use the API. + +Add the following to your `settings.yml` file: + +```yaml +search: + formats: + - html + - json +``` + +Then restart SearXNG: + +```bash +# Using Docker +docker run -d -p 8080:8080 \ + -v $(pwd)/settings.yml:/etc/searxng/settings.yml:ro \ + -e SEARXNG_BASE_URL=http://localhost:8080 \ + searxng/searxng + +# Then configure LiteLLM to use your instance +export SEARXNG_API_BASE=http://localhost:8080 +``` + +## Configuration + +### Setting API Base URL (Required) + +You **must** specify a SearXNG instance URL either via environment variable or in the search call: + +```python +# Option 1: Environment variable (Recommended) +import os +os.environ["SEARXNG_API_BASE"] = "https://your-instance.com" + +response = search( + query="AI developments", + search_provider="searxng" +) + +# Option 2: Pass directly in search call +response = search( + query="AI developments", + search_provider="searxng", + api_base="https://your-instance.com" +) +``` + +**Note:** There is no default instance URL. You must choose either a [public instance](https://searx.space/) or self-host your own. + +### Optional Authentication + +Some SearXNG instances may require authentication: + +```python +import os + +# Set API key if required +os.environ["SEARXNG_API_KEY"] = "your-api-key" + +response = search( + query="AI developments", + search_provider="searxng" +) +``` + +## Cost + +SearXNG is completely free: +- **Open source** - No licensing costs +- **Self-hosted** - Only infrastructure costs +- **Public instances** - Usually free, check instance policies + +## Advanced Usage + +### Custom Engine Selection + +```python +response = search( + query="Python tutorials", + search_provider="searxng", + engines="stackoverflow,github,reddit", # Only search these engines + categories="it" +) +``` + +### Multi-Category Search + +```python +response = search( + query="climate change", + search_provider="searxng", + categories="general,science,news", # Search multiple categories + time_range="month" +) +``` + +### Pagination + +```python +# Get page 1 +page1 = search( + query="AI research", + search_provider="searxng", + pageno=1 +) + +# Get page 2 +page2 = search( + query="AI research", + search_provider="searxng", + pageno=2 +) +``` + +## Response Format + +SearXNG returns results in the standard LiteLLM search format: + +```json +{ + "object": "search", + "results": [ + { + "title": "Example Result", + "url": "https://example.com", + "snippet": "This is the content snippet from the search result...", + "date": "2024-01-15", + "last_updated": null + } + ] +} +``` + +## Troubleshooting + +### Test Your Instance First + +If LiteLLM with searxng search provider is not working, test your SearXNG instance directly with curl: + +```bash +# Test if JSON API is working +curl -s "https://your-searxng-instance.com/search?q=test&format=json" | head -50 + +# Example with specific instance +curl -s "https://serxng-deployment-production.up.railway.app/search?q=test&format=json" | head -50 +``` + +**Expected response**: JSON with search results +**If you get HTML**: JSON format is not enabled in the instance's `settings.yml` + +### No Results + +If you get no results: + +1. **Try different engines**: Specify `engines` parameter +2. **Broaden categories**: Use multiple categories +3. **Adjust language**: Set appropriate `language` parameter + +### JSON Format Not Enabled + +If you get HTML instead of JSON: + +1. **Test with curl**: Use the curl command above to verify JSON output +2. **Self-host your own instance**: Use [our deployment repo](https://github.com/BerriAI/serxng-deployment) with JSON pre-configured +3. **Check instance configuration**: Not all public instances have JSON enabled +4. **Enable JSON manually**: Add to `settings.yml`: + ```yaml + search: + formats: + - html + - json + ``` + diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index ab1e6df125..41bf9397b0 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -398,6 +398,8 @@ const sidebars = { "search/parallel_ai", "search/google_pse", "search/dataforseo", + "search/firecrawl", + "search/searxng", ] }, { diff --git a/litellm/llms/searxng/__init__.py b/litellm/llms/searxng/__init__.py new file mode 100644 index 0000000000..91d237a8a0 --- /dev/null +++ b/litellm/llms/searxng/__init__.py @@ -0,0 +1,7 @@ +""" +SearXNG API integration module. +""" +from litellm.llms.searxng.search.transformation import SearXNGSearchConfig + +__all__ = ["SearXNGSearchConfig"] + diff --git a/litellm/llms/searxng/search/__init__.py b/litellm/llms/searxng/search/__init__.py new file mode 100644 index 0000000000..cb6fccfa9d --- /dev/null +++ b/litellm/llms/searxng/search/__init__.py @@ -0,0 +1,7 @@ +""" +SearXNG Search API module. +""" +from litellm.llms.searxng.search.transformation import SearXNGSearchConfig + +__all__ = ["SearXNGSearchConfig"] + diff --git a/litellm/llms/searxng/search/transformation.py b/litellm/llms/searxng/search/transformation.py new file mode 100644 index 0000000000..00ad9d1948 --- /dev/null +++ b/litellm/llms/searxng/search/transformation.py @@ -0,0 +1,223 @@ +""" +Calls SearXNG's /search endpoint to search the web. + +SearXNG API Reference: https://docs.searxng.org/dev/search_api.html +""" +from typing import Dict, List, Optional, TypedDict, Union + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _SearXNGSearchRequestRequired(TypedDict): + """Required fields for SearXNG Search API request.""" + q: str # Required - search query + + +class SearXNGSearchRequest(_SearXNGSearchRequestRequired, total=False): + """ + SearXNG Search API request format. + Based on: https://docs.searxng.org/dev/search_api.html + """ + categories: str # Optional - comma-separated list of categories + engines: str # Optional - comma-separated list of engines + language: str # Optional - language code + pageno: int # Optional - page number (default 1) + time_range: str # Optional - time range filter (day, month, year) + format: str # Optional - output format (json, csv, rss) - should be 'json' + + +class SearXNGSearchConfig(BaseSearchConfig): + + @staticmethod + def ui_friendly_name() -> str: + return "SearXNG" + + def get_http_method(self): + """ + SearXNG supports both GET and POST, but we'll use GET for simplicity. + """ + return "GET" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate environment and return headers. + SearXNG is open-source and doesn't require an API key by default. + Some instances may require authentication via headers. + """ + # SearXNG typically doesn't require API keys, but support optional auth + api_key = api_key or get_secret_str("SEARXNG_API_KEY") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + headers["Content-Type"] = "application/json" + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for Search endpoint with query parameters. + + SearXNG uses GET requests, so we build the full URL with query params here. + The transformed request body (data) contains the parameters needed for the URL. + """ + from urllib.parse import urlencode + + api_base = api_base or get_secret_str("SEARXNG_API_BASE") + + if not api_base: + raise ValueError( + "SEARXNG_API_BASE is not set. Please set the `SEARXNG_API_BASE` environment variable " + "or pass `api_base` parameter. Example: os.environ['SEARXNG_API_BASE'] = 'https://your-searxng-instance.com'" + ) + + # Append "/search" to the api base if it's not already there + if not api_base.endswith("/search"): + if api_base.endswith("/"): + api_base = f"{api_base}search" + else: + api_base = f"{api_base}/search" + + # Build query parameters from the transformed request body + if data and isinstance(data, dict) and "_searxng_params" in data: + params = data["_searxng_params"] + query_string = urlencode(params) + return f"{api_base}?{query_string}" + + return api_base + + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + """ + Transform Search request to SearXNG API format. + + Transforms Perplexity unified spec parameters: + - query → q + - max_results → (handled via pageno, SearXNG returns ~20 results per page) + - search_domain_filter → (not directly supported) + - country → language (approximate mapping) + - max_tokens_per_page → (not applicable, ignored) + + All other SearXNG-specific parameters are passed through as-is. + + Args: + query: Search query (string or list of strings). SearXNG only supports single string queries. + optional_params: Optional parameters for the request + + Returns: + Dict with typed request data following SearXNGSearchRequest spec + """ + if isinstance(query, list): + # SearXNG only supports single string queries, join with spaces + query = " ".join(query) + + request_data: SearXNGSearchRequest = { + "q": query, + "format": "json", # Always request JSON format + } + + # Transform Perplexity unified spec parameters to SearXNG format + if "country" in optional_params: + # Map country code to language (approximate) + country = optional_params["country"].lower() + if country == "us" or country == "uk": + request_data["language"] = "en" + elif country == "de": + request_data["language"] = "de" + elif country == "fr": + request_data["language"] = "fr" + elif country == "es": + request_data["language"] = "es" + elif country == "jp": + request_data["language"] = "ja" + else: + request_data["language"] = country # Pass through as-is + + # Handle max_results via pagination (SearXNG returns ~20 results per page by default) + # For simplicity, we'll just use page 1 and let SearXNG return its default number of results + if "max_results" in optional_params: + # Note: We could calculate pageno based on max_results, but for now we'll ignore this + # and let SearXNG return its default results + pass + + # Convert to dict before dynamic key assignments + result_data = dict(request_data) + + # Pass through all other SearXNG-specific parameters as-is + for param, value in optional_params.items(): + if param not in self.get_supported_perplexity_optional_params() and param not in result_data: + result_data[param] = value + + # Store params in special key for GET request URL building + # This will be used by get_complete_url to build the query string + return {"_searxng_params": result_data} + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform SearXNG API response to LiteLLM unified SearchResponse format. + + SearXNG → LiteLLM mappings: + - results[].title → SearchResult.title + - results[].url → SearchResult.url + - results[].content → SearchResult.snippet + - results[].publishedDate OR results[].pubdate → SearchResult.date + - No last_updated field in SearXNG response (set to None) + + Args: + raw_response: Raw httpx response from SearXNG API + logging_obj: Logging object for tracking + + Returns: + SearchResponse with standardized format + """ + response_json = raw_response.json() + + # Transform results to SearchResult objects + # Note: SearXNG doesn't natively support limiting results via API params + # It returns ~20 results per page by default + results = [] + for result in response_json.get("results", []): + # Get date from either publishedDate or pubdate field + date = result.get("publishedDate") or result.get("pubdate") + + search_result = SearchResult( + title=result.get("title", ""), + url=result.get("url", ""), + snippet=result.get("content", ""), # SearXNG uses "content" for snippet + date=date, + last_updated=None, # SearXNG doesn't provide last_updated in response + ) + results.append(search_result) + + return SearchResponse( + results=results, + object="search", + ) + diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 909fd0e77f..f7f77ac6f1 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -7971,6 +7971,14 @@ "litellm_provider": "perplexity", "mode": "search" }, + "searxng/search": { + "litellm_provider": "searxng", + "mode": "search", + "input_cost_per_query": 0.0, + "metadata": { + "notes": "SearXNG is an open-source metasearch engine. Free to use when self-hosted or using public instances." + } + }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 940241a9f4..05fc23a2c6 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2595,6 +2595,7 @@ class SearchProviders(str, Enum): GOOGLE_PSE = "google_pse" DATAFORSEO = "dataforseo" FIRECRAWL = "firecrawl" + SEARXNG = "searxng" # Create a set of all search provider values for quick lookup diff --git a/litellm/utils.py b/litellm/utils.py index 608bb36b1c..e924543df2 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7733,6 +7733,7 @@ class ProviderConfigManager: ParallelAISearchConfig, ) from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig + from litellm.llms.searxng.search.transformation import SearXNGSearchConfig from litellm.llms.tavily.search.transformation import TavilySearchConfig PROVIDER_TO_CONFIG_MAP = { @@ -7743,6 +7744,7 @@ class ProviderConfigManager: SearchProviders.GOOGLE_PSE: GooglePSESearchConfig, SearchProviders.DATAFORSEO: DataForSEOSearchConfig, SearchProviders.FIRECRAWL: FirecrawlSearchConfig, + SearchProviders.SEARXNG: SearXNGSearchConfig, } config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None) if config_class is None: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 909fd0e77f..f7f77ac6f1 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -7971,6 +7971,14 @@ "litellm_provider": "perplexity", "mode": "search" }, + "searxng/search": { + "litellm_provider": "searxng", + "mode": "search", + "input_cost_per_query": 0.0, + "metadata": { + "notes": "SearXNG is an open-source metasearch engine. Free to use when self-hosted or using public instances." + } + }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index fdc18d50ba..975ebb8a0f 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1374,6 +1374,23 @@ "rerank": false } }, + "searxng": { + "display_name": "SearXNG (`searxng`)", + "url": "https://docs.litellm.ai/docs/search/searxng", + "endpoints": { + "chat_completions": false, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "search": true + } + }, "sambanova": { "display_name": "Sambanova (`sambanova`)", "url": "https://docs.litellm.ai/docs/providers/sambanova", diff --git a/tests/search_tests/test_searxng_search.py b/tests/search_tests/test_searxng_search.py new file mode 100644 index 0000000000..50d5876973 --- /dev/null +++ b/tests/search_tests/test_searxng_search.py @@ -0,0 +1,109 @@ +import pytest +import litellm +import os +from typing import List, Union + +from tests.search_tests.base_search_unit_tests import BaseSearchTest + + +class TestSearXNGSearch(BaseSearchTest): + """ + Tests for SearXNG Search functionality. + """ + + 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)) + + 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, + ) + + # 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)}") +