mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
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:
parent
6e148255d9
commit
9bafafc2df
9 changed files with 337 additions and 1 deletions
7
litellm/llms/deepseek/search/__init__.py
Normal file
7
litellm/llms/deepseek/search/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
DeepSeek Search API module.
|
||||
"""
|
||||
|
||||
from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig
|
||||
|
||||
__all__ = ["DeepSeekSearchConfig"]
|
||||
172
litellm/llms/deepseek/search/transformation.py
Normal file
172
litellm/llms/deepseek/search/transformation.py
Normal 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 ""
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -923,7 +923,8 @@
|
|||
"batches": false,
|
||||
"rerank": false,
|
||||
"a2a": true,
|
||||
"interactions": true
|
||||
"interactions": true,
|
||||
"search": true
|
||||
}
|
||||
},
|
||||
"edenai": {
|
||||
|
|
|
|||
0
tests/unit/llms/deepseek/search/__init__.py
Normal file
0
tests/unit/llms/deepseek/search/__init__.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue