diff --git a/litellm/llms/deepseek/search/__init__.py b/litellm/llms/deepseek/search/__init__.py new file mode 100644 index 00000000000..528dceb223f --- /dev/null +++ b/litellm/llms/deepseek/search/__init__.py @@ -0,0 +1,7 @@ +""" +DeepSeek Search API module. +""" + +from litellm.llms.deepseek.search.transformation import DeepSeekSearchConfig + +__all__ = ["DeepSeekSearchConfig"] diff --git a/litellm/llms/deepseek/search/transformation.py b/litellm/llms/deepseek/search/transformation.py new file mode 100644 index 00000000000..b6bb1c343d8 --- /dev/null +++ b/litellm/llms/deepseek/search/transformation.py @@ -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 "" diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9d5e0aa8021..f5eec206e04 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 2e05107f82d..41581d702f3 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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 diff --git a/litellm/utils.py b/litellm/utils.py index ff2c9012ec8..cf3b8756e2f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9d5e0aa8021..f5eec206e04 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index c2cec2c5490..d65da3f41fd 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -923,7 +923,8 @@ "batches": false, "rerank": false, "a2a": true, - "interactions": true + "interactions": true, + "search": true } }, "edenai": { diff --git a/tests/unit/llms/deepseek/search/__init__.py b/tests/unit/llms/deepseek/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/deepseek/search/test_deepseek_search_transformation.py b/tests/unit/llms/deepseek/search/test_deepseek_search_transformation.py new file mode 100644 index 00000000000..7539876bd37 --- /dev/null +++ b/tests/unit/llms/deepseek/search/test_deepseek_search_transformation.py @@ -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)