feat(search): add DeepSeek as a /v1/search provider (#47401)

* feat(search): add DeepSeek as a /v1/search provider

Adds DeepSeekSearchConfig for POST https://api.deepseek.com/v1/tools/search, registers it under search_provider=deepseek, prices deepseek/search at 0.00075 per query and marks search support in provider_endpoints_support.json

* fix(search): reject DeepSeek queries over the 128 character limit before the call

* test(search): escape regex metacharacters in DeepSeek query limit match

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
litellm-stampbot 2026-10-19 13:35:04 -07:00 • committed by GitHub
parent 6e148255d9
commit 9bafafc2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 337 additions and 1 deletions

View file

@ -0,0 +1,7 @@
"""
DeepSeek Search API module.
"""
from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig
__all__ = ["DeepSeekSearchConfig"]

View file

@ -0,0 +1,172 @@
"""
DeepSeek Search API.
Endpoint: POST https://api.deepseek.com/v1/tools/search
Docs: https://api-docs.deepseek.com/guides/search/
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Final
import httpx
from pydantic import ConfigDict, TypeAdapter, ValidationError
from litellm.exceptions import BadRequestError
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
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
_JSON_OBJECTS: Final = TypeAdapter(Sequence[Mapping[str, object]], config=ConfigDict(hide_input_in_errors=True))
_STR_LIST: Final = TypeAdapter(list[str])
DEEPSEEK_SEARCH_MAX_COUNT: Final = 50
DEEPSEEK_SEARCH_MAX_QUERY_CHARS: Final = 128
_DEEPSEEK_SEARCH_PATH: Final = "/v1/tools/search"
# DeepSeek chat defaults DEEPSEEK_API_BASE to the /beta prefix; the search route lives at the root.
_CHAT_ONLY_BASE_SUFFIXES: Final = ("/beta", "/v1")
class DeepSeekSearchConfig(BaseSearchConfig):
DEEPSEEK_API_BASE = "https://api.deepseek.com"
@staticmethod
def ui_friendly_name() -> str:
return "DeepSeek"
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseSearchConfig.validate_environment signature
api_key: str | None = None,
api_base: str | None = None,
**kwargs: object, # kwargs-ok: BaseSearchConfig.validate_environment signature
) -> dict[str, str]: # mutable-ok: the http handler passes this straight to httpx as headers
resolved_key: Final = self.resolve_server_api_key(
caller_api_key=api_key,
caller_api_base=api_base,
key_env_vars=("DEEPSEEK_API_KEY",),
base_env_var="DEEPSEEK_API_BASE",
default_api_base=self.DEEPSEEK_API_BASE,
)
if not resolved_key:
raise ValueError("DEEPSEEK_API_KEY is not set. Set `DEEPSEEK_API_KEY` environment variable.")
return {**headers, "Authorization": f"Bearer {resolved_key}", "Content-Type": "application/json"}
def get_complete_url(
self,
api_base: str | None,
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig.get_complete_url signature
data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: base signature
**kwargs: object, # kwargs-ok: BaseSearchConfig.get_complete_url signature
) -> str:
resolved_base: Final = api_base or get_secret_str("DEEPSEEK_API_BASE") or self.DEEPSEEK_API_BASE
clean_base: Final = resolved_base.rstrip("/")
if clean_base.endswith(_DEEPSEEK_SEARCH_PATH):
return clean_base
return f"{_strip_chat_suffix(clean_base)}{_DEEPSEEK_SEARCH_PATH}"
def transform_search_request(
self,
query: str | list[str], # mutable-ok: BaseSearchConfig.transform_search_request signature
optional_params: dict[str, object], # mutable-ok: base signature
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_request signature
) -> dict[str, object]: # mutable-ok: the http handler sends this as the JSON body
"""
Unified params -> DeepSeek body: ``query`` (list joined with spaces, ``search_domain_filter``
folded in as ``site:`` operators, the whole string capped at 128 characters by DeepSeek),
``max_results`` -> ``count`` clamped to [1, 50].
``country`` and ``max_tokens_per_page`` have no DeepSeek equivalent and are dropped.
Everything outside the unified spec (``need_content``, ``ori_question``, ``localization``)
is forwarded as-is.
"""
joined_query: Final = " ".join(query) if isinstance(query, list) else query
domains: Final = _domain_filter(optional_params.get("search_domain_filter"))
count: Final = _count(optional_params.get("max_results"))
unified: Final = self.get_supported_perplexity_optional_params()
passthrough: Final = {k: v for k, v in optional_params.items() if k not in unified}
return {
**passthrough,
"query": _checked_query(_with_site_filters(joined_query, domains), domains),
**({"count": count} if count is not None else {}),
}
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_response signature
) -> SearchResponse:
response_json: Final = _JSON_OBJECT.validate_python(raw_response.json())
raw_results: Final = _JSON_OBJECTS.validate_python(response_json.get("results", ()))
return SearchResponse(
results=[_to_search_result(item) for item in raw_results],
object="search",
)
def _strip_chat_suffix(base: str) -> str:
return next(
(base.removesuffix(suffix) for suffix in _CHAT_ONLY_BASE_SUFFIXES if base.endswith(suffix)),
base,
)
def _domain_filter(raw: object) -> tuple[str, ...]:
try:
return tuple(_STR_LIST.validate_python(raw))
except ValidationError:
return ()
def _with_site_filters(query: str, domains: tuple[str, ...]) -> str:
if not domains:
return query
site_clause: Final = " OR ".join(f"site:{domain}" for domain in domains)
return f"{query} {site_clause}"
def _checked_query(query: str, domains: tuple[str, ...]) -> str:
if len(query) <= DEEPSEEK_SEARCH_MAX_QUERY_CHARS:
return query
filter_note: Final = (
f" ({len(domains)} search_domain_filter entries are sent as site: operators inside the query)"
if domains
else ""
)
raise BadRequestError(
message=(
f"DeepSeek Search accepts a query of at most {DEEPSEEK_SEARCH_MAX_QUERY_CHARS} characters, "
f"got {len(query)}{filter_note}. Shorten the query or the domain filter."
),
model="deepseek/search",
llm_provider="deepseek",
)
def _count(raw: object) -> int | None:
if isinstance(raw, bool) or not isinstance(raw, int):
return None
return max(1, min(raw, DEEPSEEK_SEARCH_MAX_COUNT))
def _to_search_result(item: Mapping[str, object]) -> SearchResult:
date: Final = item.get("date")
return SearchResult.model_validate(
{
**item,
"title": _as_str(item.get("title")),
"url": _as_str(item.get("url")),
"snippet": _as_str(item.get("snippet")),
"date": date if isinstance(date, str) else None,
"last_updated": None,
}
)
def _as_str(value: object) -> str:
return value if isinstance(value, str) else ""

View file

@ -24366,6 +24366,11 @@
"notes": "Grounding with Bing Search (G1 SKU): $14 per 1,000 transactions. Tokens for the Foundry model deployment that runs the grounded search are billed separately on that deployment."
}
},
"deepseek/search": {
"input_cost_per_query": 0.00075,
"litellm_provider": "deepseek",
"mode": "search"
},
"tinyfish/search": {
"input_cost_per_query": 0.0,
"litellm_provider": "tinyfish",

View file

@ -4469,6 +4469,7 @@ class SearchProviders(str, Enum):
AGENTCORE = "agentcore"
NIMBLE = "nimble"
BING_GROUNDING = "bing_grounding"
DEEPSEEK = "deepseek"
# Create a set of all search provider values for quick lookup

View file

@ -10297,6 +10297,7 @@ class ProviderConfigManager:
from litellm.llms.bedrock.search.transformation import AgentCoreSearchConfig
from litellm.llms.brave.search.transformation import BraveSearchConfig
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig
from litellm.llms.duckduckgo.search.transformation import DuckDuckGoSearchConfig
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
@ -10336,6 +10337,7 @@ class ProviderConfigManager:
SearchProviders.AGENTCORE: AgentCoreSearchConfig,
SearchProviders.NIMBLE: NimbleSearchConfig,
SearchProviders.BING_GROUNDING: BingGroundingSearchConfig,
SearchProviders.DEEPSEEK: DeepSeekSearchConfig,
}
config_class: Final = PROVIDER_TO_CONFIG_MAP.get(provider, None)
if config_class is None:

View file

@ -24366,6 +24366,11 @@
"notes": "Grounding with Bing Search (G1 SKU): $14 per 1,000 transactions. Tokens for the Foundry model deployment that runs the grounded search are billed separately on that deployment."
}
},
"deepseek/search": {
"input_cost_per_query": 0.00075,
"litellm_provider": "deepseek",
"mode": "search"
},
"tinyfish/search": {
"input_cost_per_query": 0.0,
"litellm_provider": "tinyfish",

View file

@ -923,7 +923,8 @@
"batches": false,
"rerank": false,
"a2a": true,
"interactions": true
"interactions": true,
"search": true
}
},
"edenai": {

View file

@ -0,0 +1,143 @@
"""
Tests for the DeepSeek Search provider (POST https://api.deepseek.com/v1/tools/search).
"""
from typing import Final
import re
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig
@pytest.mark.asyncio
async def test_asearch_sends_deepseek_body_and_maps_results(monkeypatch):
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-deepseek-key")
monkeypatch.delenv("DEEPSEEK_API_BASE", raising=False)
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"results": [
{
"rerank_score": 2.4,
"title": "DeepSeek V4 model released",
"url": "https://example.com/deepseek-v4",
"snippet": "short snippet",
"summary": "long summary for the model",
"content": "full page content",
"site_name": None,
"date": "2026-06-01T00:00:00+00:00",
"authority": 3,
},
{"title": None, "url": "https://example.com/degraded", "snippet": None, "date": None},
]
}
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_post:
response: Final = await litellm.asearch(
query=["DeepSeek V4", "release"],
search_provider="deepseek",
max_results=5,
search_domain_filter=["github.com", "huggingface.co"],
need_content=True,
)
assert mock_post.call_count == 1
call: Final = mock_post.call_args
assert call.kwargs["url"] == "https://api.deepseek.com/v1/tools/search"
assert call.kwargs["headers"]["Authorization"] == "Bearer test-deepseek-key"
assert call.kwargs["headers"]["Content-Type"] == "application/json"
assert call.kwargs["json"] == {
"query": "DeepSeek V4 release site:github.com OR site:huggingface.co",
"count": 5,
"need_content": True,
}
assert response.object == "search"
assert len(response.results) == 2
first: Final = response.results[0]
assert (first.title, first.url, first.snippet) == (
"DeepSeek V4 model released",
"https://example.com/deepseek-v4",
"short snippet",
)
assert first.date == "2026-06-01T00:00:00+00:00"
assert first.last_updated is None
assert first.model_extra["summary"] == "long summary for the model"
assert first.model_extra["content"] == "full page content"
assert first.model_extra["rerank_score"] == 2.4
degraded: Final = response.results[1]
assert (degraded.title, degraded.snippet, degraded.date) == ("", "", None)
@pytest.mark.parametrize(
("max_results", "expected_count"),
[(0, 1), (3, 3), (50, 50), (500, 50)],
)
def test_max_results_clamps_to_deepseek_count_range(max_results, expected_count):
body: Final = DeepSeekSearchConfig().transform_search_request(
query="litellm", optional_params={"max_results": max_results}
)
assert body == {"query": "litellm", "count": expected_count}
def test_query_over_deepseek_limit_fails_before_the_call():
with pytest.raises(
litellm.BadRequestError, match=re.escape("at most 128 characters, got 144 (3 search_domain_filter")
):
DeepSeekSearchConfig().transform_search_request(
query="litellm proxy search endpoint configuration guide for enterprise deployments with",
optional_params={"search_domain_filter": ["docs.litellm.ai", "github.com", "huggingface.co"]},
)
def test_unified_params_without_deepseek_equivalent_are_not_sent():
body: Final = DeepSeekSearchConfig().transform_search_request(
query="litellm",
optional_params={"country": "US", "max_tokens_per_page": 1024, "ori_question": "what is litellm"},
)
assert body == {"query": "litellm", "ori_question": "what is litellm"}
@pytest.mark.parametrize(
"api_base",
[
None,
"https://api.deepseek.com",
"https://api.deepseek.com/",
"https://api.deepseek.com/beta",
"https://api.deepseek.com/v1",
"https://api.deepseek.com/v1/tools/search",
],
)
def test_complete_url_resolves_search_route_from_chat_style_api_base(api_base, monkeypatch):
monkeypatch.delenv("DEEPSEEK_API_BASE", raising=False)
url: Final = DeepSeekSearchConfig().get_complete_url(api_base=api_base, optional_params={})
assert url == "https://api.deepseek.com/v1/tools/search"
def test_missing_api_key_raises(monkeypatch):
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
with pytest.raises(ValueError, match="DEEPSEEK_API_KEY"):
DeepSeekSearchConfig().validate_environment(headers={})
def test_search_cost_comes_from_cost_map(monkeypatch):
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-deepseek-key")
priced: Final[float] = litellm.model_cost["deepseek/search"]["input_cost_per_query"]
assert priced > 0
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"results": [{"title": "t", "url": "https://e.com", "snippet": "s"}]}
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post",
return_value=mock_response,
):
response: Final = litellm.search(query="litellm", search_provider="deepseek")
assert response._hidden_params["response_cost"] == pytest.approx(priced)