add search provider for brave search api (#19433)

* add search provider for brave search api

Introduces a minimal implementation of the Brave Search API as a search provider. Additionally, this PR introduces a test file to ensure the provider works properly, and numerous other smaller changes (e.g., changes to docs to mention the new option).

* Update transformation.py
This commit is contained in:
Sampson
2026-01-20 19:23:29 -08:00
committed by GitHub
parent ce722ab763
commit 09941dd1d1
10 changed files with 497 additions and 2 deletions
+55
View File
@@ -0,0 +1,55 @@
# Brave Search
Get started by creating a free API key via https://brave.com/search/api/.
For documentation on other parameters supported by the Brave Search API, visit https://api-dashboard.search.brave.com/api-reference/web/search.
## LiteLLM Python SDK
```python showLineNumbers title="Brave Search"
import os
from litellm import search
os.environ["BRAVE_API_KEY"] = "BSATzx..."
response = search(
query="Brave browser features",
search_provider="brave",
max_results=5
)
```
## 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: brave-search
litellm_params:
search_provider: brave
api_key: os.environ/BRAVE_API_KEY
```
### 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/brave-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{ "query": "Brave browser features", "max_results": 5 }'
```
+8 -2
View File
@@ -2,7 +2,7 @@
| Feature | Supported |
|---------|-----------|
| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `google_pse`, `dataforseo`, `firecrawl`, `searxng`, `linkup` |
| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `brave`, `google_pse`, `dataforseo`, `firecrawl`, `searxng`, `linkup` |
| Cost Tracking | ✅ |
| Logging | ✅ |
| Load Balancing | ❌ |
@@ -162,6 +162,11 @@ search_tools:
search_provider: exa_ai
api_key: os.environ/EXA_API_KEY
- search_tool_name: my-search
litellm_params:
search_provider: brave
api_key: os.environ/BRAVE_API_KEY
router_settings:
routing_strategy: simple-shuffle # or 'least-busy', 'latency-based-routing'
```
@@ -205,7 +210,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"`, `"firecrawl"`, `"searxng"`, or `"linkup"` |
| `search_provider` | string | Yes (SDK) | The search provider to use: `"perplexity"`, `"tavily"`, `"parallel_ai"`, `"exa_ai"`, `"brave"`, `"google_pse"`, `"dataforseo"`, `"firecrawl"`, `"searxng"`, or `"linkup"` |
| `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) |
@@ -264,6 +269,7 @@ The response follows Perplexity's search format with the following structure:
| Perplexity AI | `PERPLEXITYAI_API_KEY` | `perplexity` |
| Tavily | `TAVILY_API_KEY` | `tavily` |
| Exa AI | `EXA_API_KEY` | `exa_ai` |
| Brave Search | `BRAVE_API_KEY` | `brave` |
| Parallel AI | `PARALLEL_AI_API_KEY` | `parallel_ai` |
| Google PSE | `GOOGLE_PSE_API_KEY`, `GOOGLE_PSE_ENGINE_ID` | `google_pse` |
| DataForSEO | `DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD` | `dataforseo` |
+1
View File
@@ -562,6 +562,7 @@ const sidebars = {
"search/perplexity",
"search/tavily",
"search/exa_ai",
"search/brave",
"search/parallel_ai",
"search/google_pse",
"search/dataforseo",
+7
View File
@@ -0,0 +1,7 @@
"""
Brave Search API module.
"""
from litellm.llms.brave.search.transformation import BraveSearchConfig
__all__ = ["BraveSearchConfig"]
+307
View File
@@ -0,0 +1,307 @@
"""
Brave Search /web/search endpoint.
Documentation: https://api-dashboard.search.brave.com/app/documentation/web-search/get-started
"""
from __future__ import annotations
from datetime import datetime, timezone
from dateutil import parser
from typing import Dict, List, Literal, Optional, TypedDict, Union
import httpx
import re
_ISO_YMD = re.compile(r"^\s*\d{4}[-/]\d{1,2}[-/]\d{1,2}\s*$")
_UNIX_TIMESTAMP = re.compile(r"^\s*-?\d+(\.\d+)?\s*$")
BRAVE_SECTIONS = ["web", "discussions", "faqs", "faq", "news", "videos"]
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
def to_yyyy_mm_dd(
s: Union[str, int, float, None],
*,
dayfirst: bool = False,
yearfirst: bool = False,
) -> Optional[str]:
"""
Convert a string/int/float to YYYY-MM-DD; return None if parsing fails.
"""
if not s:
return None
s = str(s).strip()
# Handle Unix timestamps (seconds or milliseconds).
if _UNIX_TIMESTAMP.match(s):
try:
ts_float = float(s)
# Treat large values as milliseconds.
if ts_float > 1e11 or ts_float < -1e11:
ts_float /= 1000.0
return datetime.fromtimestamp(ts_float, tz=timezone.utc).date().isoformat()
except Exception:
return None
# If it looks like YYYY-M-D (ISO-ish), force yearfirst to avoid surprises.
try:
if _ISO_YMD.match(s):
dt = parser.parse(s, yearfirst=True, dayfirst=False, fuzzy=True)
else:
dt = parser.parse(s, yearfirst=yearfirst, dayfirst=dayfirst, fuzzy=True)
return dt.date().isoformat()
except Exception:
return None
class _BraveSearchRequestRequired(TypedDict):
"""Required fields for Brave Search API request."""
q: str # Required - search query
class BraveSearchRequest(_BraveSearchRequestRequired, total=False):
"""
Brave Search API request format.
Based on: https://api-dashboard.search.brave.com/app/documentation/web-search/get-started
"""
count: int # Optional - number of web results to return (Brave max is 20)
offset: int # Optional - pagination offset
country: str # Optional - two-letter ISO country code
search_lang: str # Optional - language to bias results
ui_lang: str # Optional - language for UI strings
freshness: str # Optional - Brave freshness window (e.g., "pd", "pw", "pm")
safesearch: str # Optional - "off" | "moderate" | "strict"
spellcheck: str # Optional - "strict" | "moderate" | "off"
text_decorations: bool # Optional - enable/disable text decorations
result_filter: str # Optional - e.g., "web"
units: str # Optional - measurement units
goggles_id: str # Optional - Brave Goggles id
goggles: str # Optional - Brave Goggles DSL
extra_snippets: bool # Optional - request extra snippets
summary: bool # Optional - include summary block
enable_rich_callback: bool # Optional - structured result blocks
include_fetch_metadata: bool # Optional - include fetch metadata
operators: bool # Optional - enable advanced operators
class BraveSearchConfig(BaseSearchConfig):
BRAVE_API_BASE = "https://api.search.brave.com/res/v1/web/search"
@staticmethod
def ui_friendly_name() -> str:
return "Brave Search"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
Brave Search API uses GET requests for search.
"""
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.
"""
api_key = api_key or get_secret_str("BRAVE_API_KEY")
if not api_key:
raise ValueError(
"BRAVE_API_KEY is not set. Set `BRAVE_API_KEY` environment variable."
)
headers["X-Subscription-Token"] = api_key
headers["Accept"] = "application/json"
headers["Accept-Encoding"] = "gzip"
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.
The Brave Search API uses GET requests and therefore needs the request
body (data) to construct query parameters in the URL.
"""
from urllib.parse import urlencode
api_base = api_base or get_secret_str("BRAVE_API_BASE") or self.BRAVE_API_BASE
# Build query parameters from the transformed request body
if data and isinstance(data, dict) and "_brave_params" in data:
params = data["_brave_params"]
query_string = urlencode(params, doseq=True)
return f"{api_base}?{query_string}"
return api_base
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
api_key: Optional[str] = None,
search_engine_id: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Transform Search request to Brave Search API format.
Transforms Perplexity unified spec parameters:
- query → q (same)
- max_results → count
- search_domain_filter → q (append domain filters)
- country → country
- max_tokens_per_page → (not applicable, ignored)
All other Brave Search API-specific parameters are passed through as-is.
Args:
query: Search query (string or list of strings). Brave Search API supports single string queries.
optional_params: Optional parameters for the request
Returns:
Dict with typed request data following Brave Search API spec
"""
if isinstance(query, list):
# Brave Search API only supports single string queries
query = " ".join(query)
request_data: BraveSearchRequest = {
"q": query,
}
# Only include "include_fetch_metadata" if it is not explicitly set to False
# This parameter results (more often than not) in a timestamp which we can use for last_updated
if (
"include_fetch_metadata" in optional_params
and optional_params["include_fetch_metadata"] is False
):
request_data["include_fetch_metadata"] = False
else:
request_data["include_fetch_metadata"] = True
# Transform unified spec parameters to Brave Search API format
if "max_results" in optional_params:
# Brave Search API supports 1-20 results per /web/search request
num_results = min(optional_params["max_results"], 20)
request_data["count"] = num_results
if "search_domain_filter" in optional_params:
# Convert to multiple "site:domain" clauses, joined by OR
domains = optional_params["search_domain_filter"]
if isinstance(domains, list) and len(domains) > 0:
request_data["q"] = self._append_domain_filters(
request_data["q"], domains
)
# Convert to dict before dynamic key assignments
result_data = dict(request_data)
# Pass through all other 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 URL building (Brave Search API uses GET not POST)
# Return a wrapper dict that stores params for get_complete_url to use
return {
"_brave_params": result_data,
}
@staticmethod
def _append_domain_filters(query: str, domains: List[str]) -> str:
"""
Add site: filters to emulate domain restriction in Brave.
"""
domain_clauses = [f"site:{domain}" for domain in domains]
domain_query = " OR ".join(domain_clauses)
return f"({query}) AND ({domain_query})"
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: Optional[LiteLLMLoggingObj],
**kwargs,
) -> SearchResponse:
"""
Transform Brave Search API response to LiteLLM unified SearchResponse format.
"""
response_json = raw_response.json()
# Transform results to SearchResult objects
results: List[SearchResult] = []
query_params = raw_response.request.url.params if raw_response.request else {}
sections_to_process = self._sections_from_params(dict(query_params))
max_results = max(1, min(int(query_params.get("count", 20)), 20))
for section in sections_to_process:
for result in response_json.get(section, {}).get("results", []):
# Because the `max_results`/`count` parameters do not affect
# the number of "discussion", "faq", "news", or "videos"
# results, we need to manually limit the number of results
# returned when an explicit limit has been provided.
if len(results) >= max_results:
break
title = result.get("title", "")
url = result.get("url", "")
snippet = result.get("description", "")
date = to_yyyy_mm_dd(result.get("page_age") or result.get("age"))
last_updated = to_yyyy_mm_dd(
result.get("fetched_content_timestamp", "")
)
search_result = SearchResult(
title=title,
url=url,
snippet=snippet,
date=date,
last_updated=last_updated,
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)
@staticmethod
def _sections_from_params(query_params: dict) -> List[str]:
"""
Returns a list of sections the user has requested via the Brave Search
API's `result_filter` parameter. If no `result_filter` parameter is
provided, returns all sections.
"""
raw_filter = query_params.get("result_filter")
requested_filters: List[str] = []
if raw_filter and isinstance(raw_filter, str):
requested_filters = [part.strip() for part in raw_filter.split(",")]
sections = [s.lower() for s in requested_filters if s.lower() in BRAVE_SECTIONS]
return sections or BRAVE_SECTIONS
+1
View File
@@ -3117,6 +3117,7 @@ class SearchProviders(str, Enum):
TAVILY = "tavily"
PARALLEL_AI = "parallel_ai"
EXA_AI = "exa_ai"
BRAVE = "brave"
GOOGLE_PSE = "google_pse"
DATAFORSEO = "dataforseo"
FIRECRAWL = "firecrawl"
+2
View File
@@ -8648,6 +8648,7 @@ class ProviderConfigManager:
"""
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
from litellm.llms.brave.search.transformation import BraveSearchConfig
from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
from litellm.llms.linkup.search.transformation import LinkupSearchConfig
@@ -8663,6 +8664,7 @@ class ProviderConfigManager:
SearchProviders.TAVILY: TavilySearchConfig,
SearchProviders.PARALLEL_AI: ParallelAISearchConfig,
SearchProviders.EXA_AI: ExaAISearchConfig,
SearchProviders.BRAVE: BraveSearchConfig,
SearchProviders.GOOGLE_PSE: GooglePSESearchConfig,
SearchProviders.DATAFORSEO: DataForSEOSearchConfig,
SearchProviders.FIRECRAWL: FirecrawlSearchConfig,
+17
View File
@@ -759,6 +759,23 @@
"search": true
}
},
"brave": {
"display_name": "Brave Search (`brave`)",
"url": "https://docs.litellm.ai/docs/search/brave",
"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
}
},
"empower": {
"display_name": "Empower (`empower`)",
"url": "https://docs.litellm.ai/docs/providers/empower",
@@ -12,6 +12,7 @@ SEARCH_PROVIDERS = [
"google_pse",
"parallel_ai",
"exa_ai",
"brave",
"firecrawl",
"searxng",
"linkup",
+98
View File
@@ -0,0 +1,98 @@
"""
Tests for Brave Search API integration.
"""
import os
import pytest
from urllib.parse import urlparse, parse_qs
from unittest.mock import AsyncMock, patch, MagicMock
import litellm
from tests.search_tests.base_search_unit_tests import BaseSearchTest
class TestBraveSearch(BaseSearchTest):
"""
Tests for Brave Search functionality with mocked network responses.
"""
def get_search_provider(self) -> str:
"""Return the search provider name"""
return "brave"
@pytest.mark.asyncio
async def test_basic_search(self):
"""
Test basic search functionality with a simple query.
"""
os.environ["BRAVE_API_KEY"] = "test-api-key"
# Create a mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"web": {
"results": [
{
"title": "Test Result 1",
"url": "https://example.com/1",
"description": "This is a test snippet for result 1",
}
]
}
}
# Mock the httpx AsyncClient get method
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
new_callable=AsyncMock,
) as mock_get:
mock_get.return_value = mock_response
# Make the search call
response = await litellm.asearch(
query="Brave browser features",
search_provider="brave",
max_results=5,
result_filter="web",
)
# Verify the get method was called once
assert mock_get.call_count == 1
# Get the actual call arguments
call_args = mock_get.call_args
# Verify URL (include_fetch_metadata=True is added by default)
parsed_url = urlparse(call_args.kwargs["url"])
assert parsed_url.scheme == "https"
assert parsed_url.netloc == "api.search.brave.com"
assert parsed_url.path == "/res/v1/web/search"
query_params = parse_qs(parsed_url.query)
assert query_params == {
"q": ["Brave browser features"],
"include_fetch_metadata": ["True"],
"count": ["5"],
"result_filter": ["web"],
}
# Verify headers contains X-Subscription-Token
headers = call_args.kwargs.get("headers", {})
assert "X-Subscription-Token" in headers
assert headers["X-Subscription-Token"] == "test-api-key"
# Note: Brave uses GET requests, so parameters are in the URL, not in JSON body
# The URL already contains all the parameters we need to verify
# Verify response structure
assert hasattr(response, "results")
assert hasattr(response, "object")
assert response.object == "search"
assert len(response.results) == 1
# Verify first result
first_result = response.results[0]
assert first_result.title == "Test Result 1"
assert first_result.url == "https://example.com/1"
assert first_result.snippet == "This is a test snippet for result 1"