From cb19652b2a05fad9b156720cfbb832516a268935 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 17 Oct 2026 16:52:31 +0800 Subject: [PATCH] core/tools+config: search through the DeepSeek Search API --- src/plyngent/cli/state.py | 20 +-- src/plyngent/config/routing.py | 41 +++-- .../lmproto/deepseek/anthropic/search.py | 148 ------------------ src/plyngent/tools/net/bing.py | 2 +- src/plyngent/tools/net/search.py | 32 ++-- tests/test_config/test_routing.py | 28 +++- .../test_deepseek_anthropic_search.py | 115 -------------- tests/test_tools/test_bing.py | 2 +- tests/test_tools/test_search.py | 26 ++- 9 files changed, 82 insertions(+), 332 deletions(-) delete mode 100644 src/plyngent/lmproto/deepseek/anthropic/search.py delete mode 100644 tests/test_lmproto/test_deepseek_anthropic_search.py diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 5491150..7b44913 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -25,7 +25,7 @@ from plyngent.cli.models_source import ( ) from plyngent.config import EffectiveProvider, ReasoningConfig, resolve_effective_provider, resolve_reasoning from plyngent.memory.database.store import normalize_workspace -from plyngent.runtime import create_client +from plyngent.runtime import create_client, normalize_http_timeout from plyngent.tools import InstanceState, SessionState from plyngent.tools.view import MemoryViewStore, session_data_view @@ -391,11 +391,11 @@ class ReplState: (see :func:`~plyngent.config.routing.local_web_search`). """ from plyngent.config.routing import ( - deepseek_search_base_url, + deepseek_api_base_url, local_web_search, ) - from plyngent.lmproto.anthropic.config import AnthropicConfig - from plyngent.lmproto.deepseek.anthropic.search import DeepseekSearchClient + from plyngent.lmproto.deepseek.search import DeepseekSearchClient + from plyngent.lmproto.openai_compatible.config import OpenAIConfig from plyngent.tools.net.bing import BingSearchBackend from plyngent.tools.net.search import ( DeepseekSearchBackend, @@ -403,7 +403,7 @@ class ReplState: ) provider = self.effective_provider - if not local_web_search(provider) or not self.model: + if not local_web_search(provider): return None backends: list[SearchBackend] = [] for source in provider.search_sources: @@ -411,12 +411,12 @@ class ReplState: backends.append( DeepseekSearchBackend( DeepseekSearchClient( - AnthropicConfig( - api_key=provider.access_key_or_token, - base_url=deepseek_search_base_url(provider), + OpenAIConfig( + access_key_or_token=provider.access_key_or_token, + base_url=deepseek_api_base_url(provider), + timeout=normalize_http_timeout(provider.timeout), ) - ), - model=self.model, + ) ) ) elif source == "bing": diff --git a/src/plyngent/config/routing.py b/src/plyngent/config/routing.py index b0726b1..c873d4f 100644 --- a/src/plyngent/config/routing.py +++ b/src/plyngent/config/routing.py @@ -98,27 +98,34 @@ def wants_web_search(effective: EffectiveProvider) -> bool: def local_web_search(effective: EffectiveProvider) -> bool: """True when this provider searches through plyngent's own ``web_search`` tool. - DeepSeek runs a hosted search only on its Anthropic-compatible surface - (``convention = "anthropic"``): the Responses and chat-completions surfaces - ignore a hosted tool — a live probe answers "I can't browse the web" with - either ``{"type": "web_search"}`` or the server-tool shape — so a DeepSeek - provider that talks them searches through - :func:`~plyngent.tools.net.search.build_web_search_tool`, which asks the - sources named by ``search_sources`` behind the scenes. + ``search_sources`` owns the local tool; ``provider_tools`` owns the hosted + one. DeepSeek's Anthropic-compatible surface can run the search inside a + response, so that combination keeps the hosted tool and the local one stays + out of the way; turning hosted tools off (``provider_tools = []``) brings + the local tool back there too. Every other surface ignores a hosted search, + so the local tool is the only one it can use. """ - if effective.preset != "deepseek" or effective.convention == "anthropic": + if effective.preset != "deepseek" or not effective.search_sources: return False - if not effective.search_sources: - return False - return wants_web_search(effective) + return not (effective.convention == "anthropic" and wants_web_search(effective)) -def deepseek_search_base_url(effective: EffectiveProvider) -> str: - """The endpoint a DeepSeek search turn runs on (its Anthropic-compatible base).""" - url = effective.url.rstrip("/") - if url.endswith("/anthropic"): - return url - return DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL +def deepseek_api_base_url(effective: EffectiveProvider) -> str: + """The platform base URL shared by model and tool APIs. + + The Anthropic convention lives under ``/anthropic`` and OpenAI-compatible + configs often carry a ``/v1`` suffix; neither belongs in the base that the + Search API client appends ``/v1/tools/search`` to. + """ + url = (effective.url or DEFAULT_DEEPSEEK_BASE_URL).strip().rstrip("/") + stripped = True + while stripped: + stripped = False + for suffix in ("/anthropic", "/v1"): + if url.endswith(suffix): + url = url[: -len(suffix)].rstrip("/") + stripped = True + return url or DEFAULT_DEEPSEEK_BASE_URL def resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider: diff --git a/src/plyngent/lmproto/deepseek/anthropic/search.py b/src/plyngent/lmproto/deepseek/anthropic/search.py deleted file mode 100644 index f226d95..0000000 --- a/src/plyngent/lmproto/deepseek/anthropic/search.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Server-side web search on DeepSeek's Anthropic-compatible endpoint. - -DeepSeek runs ``web_search`` *inside* a turn: the request carries the server -tool, and the response comes back with a ``server_tool_use`` block per query -plus the ``web_search_tool_result`` that query produced — nothing is executed -here. Results only carry a title, a URL and an ``encrypted_content`` the index -keeps the page body in, which is why the hit list is all a caller can pass on. - -Only this surface searches: the Responses and chat-completions surfaces ignore a -hosted search tool (a live probe answers "I can't browse the web" whether the -tool arrives as ``{"type": "web_search"}`` or in the server-tool shape), so a -DeepSeek provider that talks them searches through -:func:`~plyngent.tools.net.search.build_web_search_tool` instead. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, cast - -import msgspec -import niquests - -if TYPE_CHECKING: - from plyngent.lmproto.anthropic.config import AnthropicConfig - -# A search turn stops as soon as the search came back: the answer that follows is -# thrown away, so the budget only has to cover the thinking that picks the query -# and the results. A live probe with 64 tokens spent all of it before searching -# and returned no results at all. -DEFAULT_SEARCH_MAX_TOKENS = 512 -DEFAULT_SEARCH_MAX_USES = 3 -_HTTP_ERROR = 400 -_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"} - - -class SearchHit(msgspec.Struct, frozen=True, omit_defaults=True): - """One page a search source matched. - - DeepSeek's index keeps the page bodies server-side, so its hits carry a - title and a URL only; a source that ships snippets (Bing's RSS) fills - ``snippet`` too. - """ - - title: str = "" - url: str = "" - snippet: str = "" - - -def hits_from_content(content: object) -> list[SearchHit]: - """Collect the search hits from a response's content blocks (deduplicated).""" - if not isinstance(content, list): - return [] - hits: list[SearchHit] = [] - seen: set[str] = set() - for block_obj in cast("list[object]", content): - if not isinstance(block_obj, dict): - continue - block = cast("dict[str, object]", block_obj) - if block.get("type") != "web_search_tool_result": - continue - entries = block.get("content") - if not isinstance(entries, list): - continue - for entry_obj in cast("list[object]", entries): - if not isinstance(entry_obj, dict): - continue - entry = cast("dict[str, object]", entry_obj) - if entry.get("type") != "web_search_result": - continue - url = entry.get("url") - if not isinstance(url, str) or not url or url in seen: - continue - seen.add(url) - title = entry.get("title") - hits.append(SearchHit(title=title if isinstance(title, str) else "", url=url)) - return hits - - -class DeepseekSearchClient: - """``POST /messages`` with a hosted ``web_search`` tool, read for its hits.""" - - session: niquests.AsyncSession - encoder: msgspec.json.Encoder - _api_key: str - _api_version: str - - def __init__(self, config: AnthropicConfig) -> None: - self.session = niquests.AsyncSession(base_url=config.base_url, timeout=config.timeout) - self.encoder = msgspec.json.Encoder() - self._api_key = config.api_key - self._api_version = config.anthropic_version - - def _headers(self) -> dict[str, str]: - return { - "Content-Type": "application/json", - "x-api-key": self._api_key, - "anthropic-version": self._api_version, - } - - async def search( - self, - query: str, - *, - model: str, - max_uses: int = DEFAULT_SEARCH_MAX_USES, - max_tokens: int = DEFAULT_SEARCH_MAX_TOKENS, - ) -> list[SearchHit]: - """Run *query* through the server's search; return the pages it matched.""" - payload: dict[str, Any] = { - "model": model, - "max_tokens": max_tokens, - "messages": [{"role": "user", "content": query}], - "tools": [{**_SEARCH_TOOL, "max_uses": max_uses}], - } - resp = await self.session.post( - "/messages", - data=self.encoder.encode(payload), - headers=self._headers(), - stream=False, - ) - body = await self._read_body(resp) - parsed = msgspec.json.decode(body, type=dict[str, object]) - return hits_from_content(parsed.get("content")) - - async def _read_body(self, resp: object) -> bytes: - from plyngent.lmproto.openai_compatible.client import ( - http_error_message, - read_response_body, - ) - - body = await read_response_body(resp) - status = int(getattr(resp, "status_code", 0) or 0) - if status >= _HTTP_ERROR: - detail = http_error_message(status, body, what="search") or f"search HTTP {status}" - raise RuntimeError(detail) - if body is None: - msg = "search response body is empty" - raise RuntimeError(msg) - return body.encode() if isinstance(body, str) else bytes(body) - - -__all__ = [ - "DEFAULT_SEARCH_MAX_TOKENS", - "DEFAULT_SEARCH_MAX_USES", - "DeepseekSearchClient", - "SearchHit", - "hits_from_content", -] diff --git a/src/plyngent/tools/net/bing.py b/src/plyngent/tools/net/bing.py index 43c81b5..f250f7d 100644 --- a/src/plyngent/tools/net/bing.py +++ b/src/plyngent/tools/net/bing.py @@ -12,7 +12,7 @@ from __future__ import annotations import urllib.parse import xml.etree.ElementTree as ET -from plyngent.lmproto.deepseek.anthropic.search import SearchHit +from plyngent.lmproto.search import SearchHit from plyngent.tools.net.client import http_fetch SEARCH_URL = "https://www.bing.com/search" diff --git a/src/plyngent/tools/net/search.py b/src/plyngent/tools/net/search.py index 2474582..6cc6052 100644 --- a/src/plyngent/tools/net/search.py +++ b/src/plyngent/tools/net/search.py @@ -1,13 +1,15 @@ """``web_search``: the model's way to search when its surface cannot host one. -DeepSeek only searches on its Anthropic-compatible surface, so a DeepSeek -provider that talks the Responses or chat-completions surface gets this local -tool instead of a hosted one (see ``config.routing.local_web_search``): the -model calls it like any other tool and the handler asks the configured search -sources behind the scenes — ``provider.search_sources``, in fallback order, so -the next source is tried when one fails or finds nothing. A hit is a title, a -URL and (when the source has one) a snippet: read a page with ``fetch`` before -relying on its content. +DeepSeek hosts a search only on its Anthropic-compatible surface, so a DeepSeek +provider that talks the Responses or chat-completions surface — or one that +turns hosted tools off — gets this local tool instead (see +``config.routing.local_web_search``): the model calls it like any other tool and +the handler asks the configured search sources behind the scenes — +``provider.search_sources``, in fallback order, so the next source is tried when +one fails or finds nothing. DeepSeek's own source is the Search API +(``POST /v1/tools/search``), which answers in one request instead of a model +round. A hit is a title, a URL and (when the source has one) a snippet: read a +page with ``fetch`` before relying on its content. """ from __future__ import annotations @@ -20,10 +22,8 @@ from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable if TYPE_CHECKING: from collections.abc import Sequence - from plyngent.lmproto.deepseek.anthropic.search import ( - DeepseekSearchClient, - SearchHit, - ) + from plyngent.lmproto.deepseek.search import DeepseekSearchClient + from plyngent.lmproto.search import SearchHit WEB_SEARCH_TOOL_NAME = "web_search" DEFAULT_MAX_HITS = 8 @@ -36,17 +36,15 @@ class SearchBackend(Protocol): class DeepseekSearchBackend: - """DeepSeek's own index: one server-side search turn per query.""" + """DeepSeek's own index: one Search API request per query.""" _client: DeepseekSearchClient - _model: str - def __init__(self, client: DeepseekSearchClient, *, model: str) -> None: + def __init__(self, client: DeepseekSearchClient) -> None: self._client = client - self._model = model async def search(self, query: str) -> Sequence[SearchHit]: - return await self._client.search(query, model=self._model) + return await self._client.search(query) async def search_in_order( diff --git a/tests/test_config/test_routing.py b/tests/test_config/test_routing.py index 8f6f343..81a5bd9 100644 --- a/tests/test_config/test_routing.py +++ b/tests/test_config/test_routing.py @@ -7,7 +7,7 @@ from dataclasses import replace from plyngent.config.models import DeepseekProvider from plyngent.config.routing import ( EffectiveProvider, - deepseek_search_base_url, + deepseek_api_base_url, local_web_search, resolve_effective_provider, wants_web_search, @@ -46,8 +46,14 @@ def test_chat_and_responses_conventions_use_the_local_tool() -> None: assert local_web_search(_effective(convention="anthropic")) is False -def test_without_provider_tools_there_is_no_search() -> None: - assert local_web_search(_effective(convention="responses", tools=[])) is False +def test_provider_tools_only_gate_the_hosted_search() -> None: + # ``search_sources`` owns the local tool; ``provider_tools`` owns the hosted + # one. Turning hosted tools off must not take local search with it. + assert local_web_search(_effective(convention="responses", tools=[])) is True + assert local_web_search(_effective(convention="anthropic", tools=[])) is True + # With hosted tools on, the Anthropic surface keeps the hosted search and + # the local tool stays out of the way. + assert local_web_search(_effective(convention="anthropic")) is False def test_without_search_sources_there_is_no_local_tool() -> None: @@ -58,9 +64,17 @@ def test_without_search_sources_there_is_no_local_tool() -> None: assert _effective(convention="responses").search_sources == ["deepseek", "bing"] -def test_search_base_url_prefers_an_anthropic_endpoint() -> None: - assert deepseek_search_base_url(_effective(convention="responses")) == "https://api.deepseek.com/anthropic" +def test_api_base_url_drops_the_convention_and_version_paths() -> None: + # The Search API appends ``/v1/tools/search`` itself, so neither the + # Anthropic convention's ``/anthropic`` nor an OpenAI-compatible ``/v1`` + # may survive in the base. + assert deepseek_api_base_url(_effective(convention="responses")) == "https://api.deepseek.com" + assert deepseek_api_base_url(_effective(convention="anthropic")) == "https://api.deepseek.com" assert ( - deepseek_search_base_url(_effective(convention="responses", url="https://gateway.example/anthropic")) - == "https://gateway.example/anthropic" + deepseek_api_base_url(_effective(convention="anthropic", url="https://gateway.example/api/anthropic")) + == "https://gateway.example/api" + ) + assert ( + deepseek_api_base_url(_effective(convention="responses", url="https://gateway.example/v1")) + == "https://gateway.example" ) diff --git a/tests/test_lmproto/test_deepseek_anthropic_search.py b/tests/test_lmproto/test_deepseek_anthropic_search.py deleted file mode 100644 index bc170a2..0000000 --- a/tests/test_lmproto/test_deepseek_anthropic_search.py +++ /dev/null @@ -1,115 +0,0 @@ -"""DeepSeek server-side search backend (Anthropic-compatible endpoint).""" - -from __future__ import annotations - -import msgspec -import pytest - -from plyngent.lmproto.anthropic.config import AnthropicConfig -from plyngent.lmproto.deepseek.anthropic.search import ( - DeepseekSearchClient, - SearchHit, - hits_from_content, -) - - -def test_hits_from_content_dedupes_and_skips_noise() -> None: - content = [ - {"type": "thinking", "thinking": "search"}, - { - "type": "web_search_tool_result", - "tool_use_id": "call_1", - "content": [ - {"type": "web_search_result", "title": "First", "url": "https://a.example/x"}, - {"type": "web_search_result", "title": "First again", "url": "https://a.example/x"}, - {"type": "web_search_result", "url": "https://b.example/y"}, - {"type": "web_search_result", "title": "no url"}, - ], - }, - { - "type": "web_search_tool_result", - "tool_use_id": "call_2", - "content": [{"type": "web_search_tool_result_error", "error_code": "max_uses_exceeded"}], - }, - ] - hits = hits_from_content(content) - assert hits == [ - SearchHit(title="First", url="https://a.example/x"), - SearchHit(title="", url="https://b.example/y"), - ] - assert hits_from_content("not a list") == [] - - -class _Resp: - def __init__(self, status_code: int, body: bytes) -> None: - self.status_code = status_code - self.content = body - - -def _search_body() -> bytes: - return msgspec.json.encode( - { - "id": "msg_1", - "content": [ - { - "type": "server_tool_use", - "id": "call_00_1", - "name": "web_search", - "input": {"query": "DeepSeek-V4"}, - }, - { - "type": "web_search_tool_result", - "tool_use_id": "call_00_1", - "content": [ - { - "type": "web_search_result", - "title": "DeepSeek-V4 preview", - "url": "https://api-docs.deepseek.com/news/news260424/", - "encrypted_content": "opaque", - } - ], - }, - ], - "usage": {"input_tokens": 9932, "output_tokens": 377}, - } - ) - - -@pytest.mark.asyncio -async def test_search_sends_the_hosted_tool_and_reads_its_hits(monkeypatch: pytest.MonkeyPatch) -> None: - client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic")) - sent: dict[str, object] = {} - - async def fake_post(path: str, **kwargs: object) -> _Resp: - sent["path"] = path - sent.update(kwargs) - return _Resp(200, _search_body()) - - monkeypatch.setattr(client.session, "post", fake_post) - hits = await client.search("DeepSeek-V4", model="deepseek-flash") - - assert hits == [SearchHit(title="DeepSeek-V4 preview", url="https://api-docs.deepseek.com/news/news260424/")] - assert sent["path"] == "/messages" - assert sent["stream"] is False - data = sent["data"] - assert isinstance(data, bytes) - payload = msgspec.json.decode(data, type=dict[str, object]) - assert payload["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}] - # A search turn stops as soon as it has searched; the answer is not needed. - assert payload["max_tokens"] == 512 - assert payload["messages"] == [{"role": "user", "content": "DeepSeek-V4"}] - headers = sent["headers"] - assert isinstance(headers, dict) - assert headers["x-api-key"] == "sk-test" - - -@pytest.mark.asyncio -async def test_search_raises_with_the_endpoint_detail(monkeypatch: pytest.MonkeyPatch) -> None: - client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic")) - - async def fake_post(path: str, **kwargs: object) -> _Resp: - return _Resp(422, b'{"error": {"message": "tools[0]: unknown variant"}}') - - monkeypatch.setattr(client.session, "post", fake_post) - with pytest.raises(RuntimeError, match="unknown variant"): - _ = await client.search("DeepSeek-V4", model="deepseek-flash") diff --git a/tests/test_tools/test_bing.py b/tests/test_tools/test_bing.py index 3587805..a52445a 100644 --- a/tests/test_tools/test_bing.py +++ b/tests/test_tools/test_bing.py @@ -4,7 +4,7 @@ from __future__ import annotations import pytest -from plyngent.lmproto.deepseek.anthropic.search import SearchHit +from plyngent.lmproto.search import SearchHit from plyngent.tools.net.bing import parse_bing_rss DOCUMENT = """ diff --git a/tests/test_tools/test_search.py b/tests/test_tools/test_search.py index 11af7fe..961791b 100644 --- a/tests/test_tools/test_search.py +++ b/tests/test_tools/test_search.py @@ -2,12 +2,10 @@ from __future__ import annotations -from typing import Any - import pytest from plyngent.agent.tools import ToolTag -from plyngent.lmproto.deepseek.anthropic.search import SearchHit +from plyngent.lmproto.search import SearchHit from plyngent.tools.net.search import ( WEB_SEARCH_TOOL_NAME, build_web_search_tool, @@ -93,25 +91,21 @@ async def test_handler_without_sources_says_so() -> None: assert "no source configured" in await definition.handler(query="q") -def test_deepseek_backend_passes_the_model_through() -> None: +@pytest.mark.asyncio +async def test_deepseek_backend_asks_the_search_api() -> None: from plyngent.tools.net.search import DeepseekSearchBackend class _Client: def __init__(self) -> None: - self.seen: list[tuple[str, str]] = [] + self.queries: list[str] = [] - async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]: - self.seen.append((query, model)) + async def search(self, query: str) -> list[SearchHit]: + self.queries.append(query) return _hits(("T", "https://example.com")) - client: Any = _Client() - backend = DeepseekSearchBackend(client, model="deepseek-flash") + client = _Client() + backend = DeepseekSearchBackend(client) # type: ignore[arg-type] — structural stub - async def run() -> list[SearchHit]: - return list(await backend.search("q")) - - import asyncio - - hits = asyncio.run(run()) + hits = await backend.search("q") assert [hit.url for hit in hits] == ["https://example.com"] - assert client.seen == [("q", "deepseek-flash")] + assert client.queries == ["q"]